feat: json encode/decode + as, .wob v2 class metadata — log-watcher compiles

docs/examples/log-watcher (1285 lines, 7 files) now compiles clean: 0
diagnostics, a 35KB .wob written. corpus 71/0, woc runtest 565/0, every wovm
unit gate green (both dispatch flavors).

- .wob v2: each class row gains three u32 per-field arrays — the field's NAME
  constant, the CLASS it refers to (or the json-raw marker), and a container
  field's ELEMENT kinds. json is then a runtime service driven by metadata
  instead of per-type generated code. loader/emitter/disassembler/test
  assembler all read and write v2; field-name constants are interned with the
  rest of the pool (interning during serialization silently loses them)
- runtime/src/json.c (new): encode by static kind + object headers + class
  table (nested records need no static knowledge); decode parses and BINDS
  straight into the target class — keys matched to field names, nested objects
  built as the field's class, arrays as a multi of the field's element kind,
  unknown keys skipped, absent keys nil. Malformed input is nil, never a trap
- `as`: `json.decode(text) as T` is the one cast this language has (WO-E403
  for any other `as`, and for a bare json.decode with no target type). Its
  result is `?T`, which is why the decode and the target are one instruction
- json.Value: a reserved type name for a value the source does not inspect —
  the raw JSON slice, kind TEXT, re-emitted verbatim by encode
- docs: 00-wob-format.md is now the v2 reference (class metadata, TRY/ENDTRY,
  the whole builtin surface, WO_T_IO); 08-builtin-surface.md documents the
  text/container builtins, the OS modules with their predeclared records, and
  json's two documented limits (Bool encodes 0/1, floats truncate)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-14 17:08:46 +02:00
parent f59340a1e3
commit ebf82ca9c0
23 changed files with 1031 additions and 88 deletions

View file

@ -253,6 +253,13 @@ and expr_kind =
literal without lookahead nothing else needs. *)
| ListLit of expr list
| MapLit
(* `expr as Type` — a CHECKED conversion, not a reinterpretation: its only
meaning in this language is "decode this JSON text into that type",
yielding `?Type` (nil when the text does not fit). Anything else is a
WO-E403 at emission: there is no reinterpret-cast in the doctrine (the
systems-track spec's reject table lists `cast`), and this form exists
only because a decode's result type cannot be inferred. *)
| As of expr * field_ty
(* haxe-parity Task 5: `try body catch (ename) handler` — an expression,
like `switch`. `body` is an expression (the workload's only form);
`handler` is a `stmt list` so both arm spellings share one shape,

View file

@ -160,7 +160,7 @@ let dump (img : string) : string =
let line fmt = Buffer.add_string out (fmt ^ "\n") in
if u32 img 0 <> magic then raise (Bad "bad magic");
let ver = u32 img 4 in
if ver <> 1 then raise (Bad (Printf.sprintf "unsupported version %d" ver));
if ver <> 2 then raise (Bad (Printf.sprintf "unsupported version %d" ver));
let coff = u32 img 8 and ccnt = u32 img 12 in
let koff = u32 img 16 and kcnt = u32 img 20 in
let ioff = u32 img 24 and icnt = u32 img 28 in
@ -204,10 +204,20 @@ let dump (img : string) : string =
o := !o + 12;
let kinds = List.init fcnt (fun j -> kind_name (u8 img (!o + j))) in
o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4);
(* v2 per-field metadata: names, referenced class ids, element kinds. The
dump shows each field as name:kind — the names are what json.encode
renders as keys, so a wrong one is worth seeing. *)
let names = List.init fcnt (fun j -> u32 img (!o + (j * 4))) in
o := !o + (fcnt * 12);
let fields =
List.map2
(fun nmk k -> if nmk = 0xFFFFFFFF then k else Printf.sprintf "%s:%s" (kname nmk) k)
names kinds
in
line
(Printf.sprintf "c%-3d %s flags=%s fields=[%s]" i (kname nm)
(if flags land 1 <> 0 then "gc" else "-")
(String.concat ", " kinds))
(String.concat ", " fields))
done;
(* interfaces + vtable rows *)
line "== INTERFACES ==";

View file

@ -66,6 +66,7 @@ let kind_label (k : Token.kind) : string =
| Token.KwTry -> "KW_TRY"
| Token.KwCatch -> "KW_CATCH"
| Token.KwNil -> "KW_NIL"
| Token.KwAs -> "KW_AS"
| Token.LBrace -> "LBRACE"
| Token.RBrace -> "RBRACE"
| Token.LParen -> "LPAREN"
@ -226,6 +227,7 @@ let rec expr_str (e : Ast.expr) : string =
| Ast.ListLit items -> Printf.sprintf "[%s]" (String.concat ", " (List.map expr_str items))
| Ast.MapLit -> "{}"
| Ast.NilLit -> "nil"
| Ast.As (inner, ty) -> Printf.sprintf "%s as %s" (expr_str inner) (field_ty_str ty)
(* Like SWITCH above: a one-line summary, not a full unparse of the
catch arm's statements. *)
| Ast.Try { body; ename; handler } ->

View file

@ -152,7 +152,7 @@ let stdlib_not_linked_code = Diag.emitter_prefix ^ "06"
============================================================ *)
let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *)
let wob_version = 1
let wob_version = 2 (* v2: per-field class-table metadata *)
let wob_hdr_size = 44
let wob_none = 0xFFFFFFFF
let k_int = 0
@ -256,6 +256,11 @@ let b_map_key_at = 37
let b_map_val_at = 38
let b_multi_set = 39
(* json (runtime/src/json.c): encode takes the value's static kind as its
second argument, decode the class id to build as its second. *)
let b_json_encode = 57
let b_json_decode = 58
let ins_abc op a b c = op lor (a lsl 8) lor (b lsl 16) lor (c lsl 24)
let ins_abx op a bx = op lor (a lsl 8) lor (bx lsl 16)
let ins_asbx op a sbx = ins_abx op a (sbx + 32768)
@ -947,6 +952,7 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option
| ListLit [] | MapLit -> None
(* haxe-parity Task 6: contextual on its destination (see owner.ml). *)
| NilLit -> None
| As (_, ty) -> Some (Nullable ty)
(* haxe-parity Task 5: a `try` yields its try arm's type — types.ml has
already required the catch arm to agree. *)
| Try t -> ty_of_expr p f t.body
@ -1296,6 +1302,34 @@ let check_field_idx (p : pctx) (f : fstate) (pos : Ast.pos) (v : int) : int =
takes a bare identifier), so a fresh container must be created
somewhere its type is declared — otherwise WO-E403 at the creation
site, where the reader can act on it. *)
(* v2 class-table metadata for one field (runtime/src/wob.h):
- field_class: the class a field refers to — its own class for an
owned/@gc field, its ELEMENT's class for a container of records — or the
json-raw marker for a `json.Value` field, else "none".
- field_elem: a container field's element kinds (a multi's element kind; a
map's key kind and value kind packed as the container_imm immediate),
0 for everything else.
Both exist so json.encode/json.decode can work off metadata instead of
per-type generated code. *)
let wob_field_json_raw = 0xFFFFFFFE
let field_class_meta (p : pctx) (ty : Ast.field_ty) : int =
let name_of t = match t with Ast.Scalar n -> Some n | _ -> None in
match unwrap ty with
| Ast.Scalar n when n = Types.json_value_type -> wob_field_json_raw
| Ast.Scalar n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none)
| Ast.Multi e | Ast.Map (_, e) -> (
match name_of (Ast.Scalar e) with
| Some n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none)
| None -> wob_none)
| Ast.Ref _ | Ast.Nullable _ -> wob_none
let field_elem_meta (p : pctx) (ty : Ast.field_ty) : int =
match unwrap ty with
| Ast.Multi e -> field_kind p (Ast.Scalar e)
| Ast.Map (k, v) -> field_kind p (Ast.Scalar k) lor (field_kind p (Ast.Scalar v) lsl 4)
| _ -> 0
let container_imm (p : pctx) (expected : Ast.field_ty option) (map : bool) : int option =
match expected with
| Some t -> (
@ -1371,6 +1405,34 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
sync_mask p f v e.id;
put f (ins_abc op_builtin dst imm b_map_new))
| Try t -> emit_try p f v ~dst ?expected e t.body t.ename t.handler
(* `json.decode(text) as T` is the ONE `as` this language has: a checked
decode, lowered to json_decode(text, class id of T). The decode needs
the target class, and the target class is exactly what `as` names — so
the two are one instruction, never separable. Any other `as` (and a
bare `json.decode(...)` with no `as`) is WO-E403: there is no
reinterpret cast in the doctrine. *)
| As (inner, ty) -> (
let target = match unwrap ty with Scalar n -> class_of_name p n | _ -> None in
match (inner.kind, target) with
| Call ({ kind = Field ({ kind = Ident "json"; _ }, "decode"); _ }, [ src ]), Some cid ->
let base = alloc_temps p f e.pos 2 in
let save = f.f_temp in
emit_expr p f v ~dst:base src;
f.f_temp <- save;
put f (ins_abx op_loadk (base + 1) (check_bx p f e.pos "constant" (const_int p cid)));
sync_mask p f v e.id;
f.f_cur_line <- e.pos.line;
put f (ins_abc op_builtin dst base b_json_decode)
| Call ({ kind = Field ({ kind = Ident "json"; _ }, "decode"); _ }, _), None ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:"`as` needs a declared class or record type to decode into";
put f (ins_abx op_loadk dst (const_int p 0))
| _ ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:
"`as` is only a checked JSON decode (`json.decode(text) as T`) — this language has no \
reinterpret cast";
put f (ins_abx op_loadk dst (const_int p 0)))
| Ident n -> (
match lookup_local f n with
| Some (r, _) -> if r <> dst then put f (ins_abc op_move dst r 0)
@ -2300,11 +2362,38 @@ and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : As
(* the systems stdlib: one builtin per member. A member whose
result is a record takes that record's class id as its last
argument, so the VM allocates what it fills (sysio.c). *)
match Types.stdlib_member alias mname with
match (if alias = "json" then None else Types.stdlib_member alias mname) with
| None when alias = "json" && mname = "encode" -> (
(* json.encode(x): the VM needs x's STATIC kind, since a register
alone cannot say whether it holds an i64 or a pointer.
Everything below the top level comes from object headers and
the class table (runtime/src/json.c). *)
match args with
| [ a ] ->
let base = alloc_temps p f e.pos 2 in
let save = f.f_temp in
emit_expr p f v ~dst:base a;
f.f_temp <- save;
let kind =
match ty_of_expr p f a with Some t -> field_kind p t | None -> 3 (* Text *)
in
put f (ins_abx op_loadk (base + 1) (check_bx p f e.pos "constant" (const_int p kind)));
sync_mask p f v e.id;
f.f_cur_line <- e.pos.line;
put f (ins_abc op_builtin dst base b_json_encode)
| _ ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:"`json.encode` takes exactly one argument";
put f (ins_abx op_loadk dst (const_int p 0)))
| None when alias = "json" && mname = "decode" ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:
"`json.decode(text)` needs a target type — write `json.decode(text) as T`, whose \
result is `?T`";
put f (ins_abx op_loadk dst (const_int p 0))
| None ->
err p ~code:stdlib_not_linked_code ~file:f.f_file ~pos:e.pos
~message:
(Printf.sprintf "stdlib module `%s` has no member `%s`" alias mname);
~message:(Printf.sprintf "stdlib module `%s` has no member `%s`" alias mname);
put f (ins_abx op_loadk dst (const_int p 0))
| Some sm ->
if List.length args <> sm.Types.sm_arity then begin
@ -3572,6 +3661,13 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string)
(* names are constants; interning them first keeps the pool's low
indexes stable and readable in a disassembly *)
let class_name_k = Array.map (fun c -> const_text p c.cr_name) p_classes in
(* Field-name constants are interned HERE, with every other constant, and
never during serialization: the constant pool is written before the
class table, so a name interned later would be missing from the image
(found the hard way — the loader rejected every class). *)
let class_field_names =
Array.map (fun c -> Array.map (fun ((fname : string), _) -> const_text p fname) c.cr_fields) p_classes
in
let iface_name_k = Array.map (fun i -> const_text p i.ir_name) p_ifaces in
let method_name_k = Array.map (fun m -> const_text p m.mr_name) p_methods in
(* ---- pass 2: method bodies ---- *)
@ -3644,7 +3740,14 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string)
let pad = (4 - (Array.length c.cr_fields mod 4)) mod 4 in
for _ = 1 to pad do
Buf.u8 cls 0
done)
done;
(* v2 per-field metadata (wob.h's "class-table field metadata"): the
names json.encode renders as keys, the classes json.decode has to
build for a nested field, and the element kinds a container field
needs when decode creates one. *)
Array.iter (fun kidx -> Buf.u32 cls kidx) class_field_names.(cid);
Array.iter (fun (_, ty) -> Buf.u32 cls (field_class_meta p ty)) c.cr_fields;
Array.iter (fun (_, ty) -> Buf.u32 cls (field_elem_meta p ty)) c.cr_fields)
p_classes;
let ifs = Buf.create () in
Array.iteri

View file

@ -139,6 +139,7 @@ let keyword_kind = function
| "try" -> Some Token.KwTry
| "catch" -> Some Token.KwCatch
| "nil" -> Some Token.KwNil
| "as" -> Some Token.KwAs
| "INSERT" -> Some Token.KwInsert
| "SELECT" -> Some Token.KwSelect
| _ -> None

View file

@ -514,6 +514,9 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option =
(* haxe-parity Task 6: `nil` is the zero word — contextual on its
destination, and never something this frame owns. *)
| NilLit -> None
(* a checked decode's value is a fresh instance of the named type (or
nil) — owned, so the binding that holds it gets its drop *)
| As (_, ty) -> Some (Nullable ty)
(* haxe-parity Task 5: a `try` yields its try arm's type (types.ml has
already required the catch arm to agree). *)
| Try t -> expr_ty ctx t.body
@ -1069,6 +1072,7 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
literal-specific one. *)
| ListLit items -> List.iter (read_expr ctx) items
| MapLit | NilLit -> ()
| As (inner, _) -> read_expr ctx inner
| Try t -> analyze_try ctx e t.body t.ename t.handler
| DbStub _ ->
(* trap-capable: the frame needs its drop map here *)

View file

@ -850,6 +850,17 @@ and parse_multiplicative (st : state) : Ast.expr =
done;
!lhs
(* `as` binds tighter than every binary operator and looser than a call, so
`json.decode(raw) as FileConfig` casts the call's result, and
`x as T == y` compares the cast value. *)
and parse_as (st : state) (e : Ast.expr) : Ast.expr =
if peek st <> Token.KwAs then e
else begin
ignore (advance st);
let ty = parse_field_ty st in
parse_as st { Ast.id = fresh_id st; pos = e.Ast.pos; kind = Ast.As (e, ty) }
end
and parse_unary (st : state) : Ast.expr =
match peek st with
| Token.Dash ->
@ -858,7 +869,7 @@ and parse_unary (st : state) : Ast.expr =
ignore (advance st);
let operand = parse_unary st in
{ Ast.id; pos; kind = Ast.Unary (Ast.Neg, operand) }
| _ -> parse_postfix st
| _ -> parse_as st (parse_postfix st)
and parse_postfix (st : state) : Ast.expr =
let base = ref (parse_primary st) in
@ -1767,6 +1778,7 @@ let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : As
| Ast.Interp inner -> { e with Ast.kind = Ast.Interp (subst_expr consts bound inner) }
| Ast.ListLit items -> { e with Ast.kind = Ast.ListLit (List.map (subst_expr consts bound) items) }
| Ast.MapLit | Ast.NilLit -> e
| Ast.As (inner, ty) -> { e with Ast.kind = Ast.As (subst_expr consts bound inner, ty) }
| Ast.Try { body; ename; handler } ->
{ e with
Ast.kind =

View file

@ -93,6 +93,9 @@ type kind =
identifier — `nil` appears in the corpus and the driving workload
only ever as this literal. *)
| KwNil
(* haxe-parity: `expr as Type` — the checked-decode cast. Only meaningful
over `json.decode(text)`, whose result has no type until one is named. *)
| KwAs
(* haxe-parity Task 4: `typedef Name = { ... }` structural records. A
real keyword (grepped the corpus/sample first, same discipline as
every keyword above — `typedef` appears only as this declaration's

View file

@ -173,6 +173,12 @@ let stdlib_modules = [ "fs"; "proc"; "net"; "time"; "json"; "env" ]
let is_stdlib_module (name : string) : bool = List.mem name stdlib_modules
(* The one reserved stdlib TYPE name: `json.Value`, a decoded JSON value.
Represented as a Text holding the raw JSON slice (see wob_kind_of_typ),
so `json.encode(v)` re-emits it verbatim and nothing has to model a
dynamic value tree. *)
let json_value_type = "json.Value"
(* haxe-parity Task 5: the record `catch (e)` binds — the VM's structured
trap error, one shape forever (spec §6). Predeclared rather than
written: no source declares it, every program that catches gets it, and
@ -252,7 +258,14 @@ let stdlib_members : stdlib_member list =
m "net" "write" 2 54 None None;
m "net" "close" 1 55 None None;
(* proc *)
m "proc" "run" 2 56 (Some (TNullable (TScalar proc_record_name))) (Some proc_record_name) ]
m "proc" "run" 2 56 (Some (TNullable (TScalar proc_record_name))) (Some proc_record_name);
(* json — both members are lowered specially (emit.ml): encode needs its
argument's static kind, and decode has no type until an `as` names one,
so neither goes through the generic builtin path. They are listed here
for their SIGNATURES: `json.encode(x) -> Text`, and `json.decode(t)`
yielding nothing on its own. *)
m "json" "encode" 1 57 (Some (TScalar "Text")) None;
m "json" "decode" 1 58 None None ]
let stdlib_member (m : string) (name : string) : stdlib_member option =
List.find_opt (fun s -> s.sm_module = m && s.sm_name = name) stdlib_members
@ -339,7 +352,11 @@ let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind =
WO_K_TEXT. Emitting WO_K_SCALAR here leaked every string a
class owned. Found by the emitter, this function's first
caller. *)
if name = "Text" then WO_K_TEXT
(* `json.Value` is a Text at the representation level: a decoded
value the source never inspects, carrying the raw JSON slice it
came from, which json.encode emits back verbatim. Kinding it TEXT
is what makes it drop correctly and pass through concatenation. *)
if name = "Text" || name = json_value_type then WO_K_TEXT
else if is_builtin_scalar name then WO_K_SCALAR
else if StringMap.mem name syms.unions then
(* haxe-parity Task 4: an all-bare union value is a plain
@ -993,6 +1010,9 @@ let typecheck_program ~file ~(module_of : string -> string)
as an empty container literal — nothing about the literal itself
says which `?T` it is the absent value of. *)
| NilLit -> None
(* A checked decode yields `?T` — the named type, or nil when the text
did not fit it. *)
| As (_, ty) -> Some (TNullable (resolve_field_ty ty))
(* haxe-parity Task 5: a `try` expression's type is its try arm's — the
handler is checked to agree (typecheck_expr below), so either arm
would answer, and the try arm is the one that always has a value. *)
@ -1342,6 +1362,9 @@ let typecheck_program ~file ~(module_of : string -> string)
what lets a comparison or a binding treat it as the absent value of
whatever `?T` it meets. *)
| NilLit -> { typ = TScalar "Int"; is_nil = true }
| As (inner, ty) ->
let _ = typecheck_expr env cenv inner in
{ typ = TNullable (resolve_field_ty ty); is_nil = false }
| Try { body; ename; handler } ->
let body_res = typecheck_expr env cenv body in
(* The catch arm sees exactly one new name: the error record. *)
@ -2024,6 +2047,7 @@ and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : e
| Interp inner -> walk_expr bound visit inner
| ListLit items -> List.iter (walk_expr bound visit) items
| MapLit | NilLit -> ()
| As (inner, _) -> walk_expr bound visit inner
| Try { body; ename; handler } ->
walk_expr bound visit body;
walk_block (StringSet.add ename bound) visit handler

View file

@ -1,14 +1,16 @@
== CONSTANTS ==
k0 TEXT "Cache"
k1 TEXT "Holder"
k2 TEXT "read"
k3 TEXT "proven"
k4 TEXT "main"
k5 INT 41
k6 INT 1
k2 TEXT "hits"
k3 TEXT "cache"
k4 TEXT "read"
k5 TEXT "proven"
k6 TEXT "main"
k7 INT 41
k8 INT 1
== CLASSES ==
c0 Cache flags=gc fields=[SCALAR]
c1 Holder flags=- fields=[GCREF]
c0 Cache flags=gc fields=[hits:SCALAR]
c1 Holder flags=- fields=[cache:GCREF]
== INTERFACES ==
== VTABLES ==
== METHODS ==
@ -31,7 +33,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY]
drops: pc 14 owned={} gc={r0}
drops: pc 15 owned={} gc={}
0000 NEW r0, c0
0001 LOADK r1, k5
0001 LOADK r1, k7
0002 SETF r0, f0, r1
0003 NEW r1, c1
0004 MOVE r2, r0
@ -40,7 +42,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY]
0007 MOVE r4, r1
0008 CALL r4, m1
0009 MOVE r3, r4
0010 LOADK r5, k6
0010 LOADK r5, k8
0011 ADD r2, r3, r5
0012 BUILTIN r2, r2, print_int
0013 DROP r1

View file

@ -1,17 +1,18 @@
== CONSTANTS ==
k0 TEXT "Book"
k1 TEXT "Toy"
k2 TEXT "Priced"
k3 TEXT "current_price"
k4 TEXT "quote"
k5 TEXT "main"
k6 INT 2
k7 INT 3
k8 INT 10
k9 INT 5
k2 TEXT "base"
k3 TEXT "Priced"
k4 TEXT "current_price"
k5 TEXT "quote"
k6 TEXT "main"
k7 INT 2
k8 INT 3
k9 INT 10
k10 INT 5
== CLASSES ==
c0 Book flags=- fields=[SCALAR]
c1 Toy flags=- fields=[SCALAR]
c0 Book flags=- fields=[base:SCALAR]
c1 Toy flags=- fields=[base:SCALAR]
== INTERFACES ==
i0 Priced methods=1 slots=s0..s0
== VTABLES ==
@ -22,14 +23,14 @@ m0 current_price args=1 regs=3 [class c0]
lines: 0->15
drops: (none)
0000 GETF r1, r0, f0
0001 LOADK r2, k6
0001 LOADK r2, k7
0002 ADD r1, r1, r2
0003 RET r1
m1 current_price args=1 regs=3 [class c1]
lines: 0->23
drops: (none)
0000 GETF r1, r0, f0
0001 LOADK r2, k7
0001 LOADK r2, k8
0002 MUL r1, r1, r2
0003 RET r1
m2 quote args=1 regs=2 [free fn]
@ -45,10 +46,10 @@ m3 main args=0 regs=4 [free fn] [ENTRY]
drops: pc 19 owned={r0} gc={}
drops: pc 20 owned={} gc={}
0000 NEW r0, c0
0001 LOADK r1, k8
0001 LOADK r1, k9
0002 SETF r0, f0, r1
0003 NEW r1, c1
0004 LOADK r2, k9
0004 LOADK r2, k10
0005 SETF r1, f0, r2
0006 MOVE r3, r0
0007 CALL r3, m2

View file

@ -1,14 +1,15 @@
== CONSTANTS ==
k0 TEXT "Item"
k1 TEXT "consume"
k2 TEXT "twice"
k3 TEXT "main"
k4 INT 2
k5 INT 3
k6 INT 5
k7 INT 0
k1 TEXT "n"
k2 TEXT "consume"
k3 TEXT "twice"
k4 TEXT "main"
k5 INT 2
k6 INT 3
k7 INT 5
k8 INT 0
== CLASSES ==
c0 Item flags=- fields=[SCALAR]
c0 Item flags=- fields=[n:SCALAR]
== INTERFACES ==
== VTABLES ==
== METHODS ==
@ -31,12 +32,12 @@ m1 twice args=2 regs=6 [free fn]
drops: pc 14 owned={r2} gc={}
drops: pc 19 owned={} gc={}
0000 NEW r2, c0
0001 LOADK r3, k4
0001 LOADK r3, k5
0002 SETF r2, f0, r3
0003 MOVE r3, r1
0004 JZ r3, -> 0013
0005 NEW r3, c0
0006 LOADK r4, k5
0006 LOADK r4, k6
0007 SETF r3, f0, r4
0008 GETF r4, r3, f0
0009 DROP r3
@ -54,9 +55,9 @@ m2 main args=0 regs=4 [free fn] [ENTRY]
lines: 0->24 7->23
drops: (none)
0000 NEW r1, c0
0001 LOADK r3, k6
0001 LOADK r3, k7
0002 SETF r1, f0, r3
0003 LOADK r2, k7
0003 LOADK r2, k8
0004 CALL r1, m1
0005 MOVE r0, r1
0006 BUILTIN r0, r0, print_int

View file

@ -1,21 +1,23 @@
== CONSTANTS ==
k0 TEXT "Item"
k1 TEXT "Bag"
k2 TEXT "touch"
k3 TEXT "pair"
k4 TEXT "fixed"
k5 TEXT "touch3"
k6 TEXT "triple"
k7 TEXT "write_through"
k8 TEXT "main"
k9 INT 0
k10 INT 1
k11 INT 5
k12 INT 2
k13 INT 3
k2 TEXT "n"
k3 TEXT "items"
k4 TEXT "touch"
k5 TEXT "pair"
k6 TEXT "fixed"
k7 TEXT "touch3"
k8 TEXT "triple"
k9 TEXT "write_through"
k10 TEXT "main"
k11 INT 0
k12 INT 1
k13 INT 5
k14 INT 2
k15 INT 3
== CLASSES ==
c0 Item flags=- fields=[SCALAR]
c1 Bag flags=- fields=[MULTI]
c0 Item flags=- fields=[n:SCALAR]
c1 Bag flags=- fields=[items:MULTI]
== INTERFACES ==
== VTABLES ==
== METHODS ==
@ -48,10 +50,10 @@ m2 fixed args=1 regs=5 [free fn]
lines: 0->27
drops: (none)
0000 GETF r3, r0, f0
0001 LOADK r4, k9
0001 LOADK r4, k11
0002 BUILTIN r1, r3, multi_get
0003 GETF r3, r0, f0
0004 LOADK r4, k10
0004 LOADK r4, k12
0005 BUILTIN r2, r3, multi_get
0006 CALL r1, m0
0007 RET r1
@ -97,7 +99,7 @@ m5 write_through args=3 regs=7 [free fn]
0003 GETF r5, r0, f0
0004 MOVE r6, r2
0005 BUILTIN r4, r5, multi_get
0006 LOADK r5, k11
0006 LOADK r5, k13
0007 BORROW_X r4
0008 BORROW_S r3
0009 SETF r4, f0, r5
@ -114,22 +116,22 @@ m6 main args=0 regs=6 [free fn] [ENTRY]
0002 SETF r0, f0, r1
0003 GETF r1, r0, f0
0004 NEW r2, c0
0005 LOADK r3, k10
0005 LOADK r3, k12
0006 SETF r2, f0, r3
0007 BUILTIN r1, r1, multi_push
0008 GETF r1, r0, f0
0009 NEW r2, c0
0010 LOADK r3, k12
0010 LOADK r3, k14
0011 SETF r2, f0, r3
0012 BUILTIN r1, r1, multi_push
0013 GETF r1, r0, f0
0014 NEW r2, c0
0015 LOADK r3, k13
0015 LOADK r3, k15
0016 SETF r2, f0, r3
0017 BUILTIN r1, r1, multi_push
0018 MOVE r2, r0
0019 LOADK r3, k9
0020 LOADK r4, k10
0019 LOADK r3, k11
0020 LOADK r4, k12
0021 CALL r2, m1
0022 MOVE r1, r2
0023 BUILTIN r1, r1, print_int
@ -138,15 +140,15 @@ m6 main args=0 regs=6 [free fn] [ENTRY]
0026 MOVE r1, r2
0027 BUILTIN r1, r1, print_int
0028 MOVE r2, r0
0029 LOADK r3, k9
0030 LOADK r4, k10
0031 LOADK r5, k12
0029 LOADK r3, k11
0030 LOADK r4, k12
0031 LOADK r5, k14
0032 CALL r2, m4
0033 MOVE r1, r2
0034 BUILTIN r1, r1, print_int
0035 MOVE r2, r0
0036 LOADK r3, k9
0037 LOADK r4, k10
0036 LOADK r3, k11
0037 LOADK r4, k12
0038 CALL r2, m5
0039 MOVE r1, r2
0040 BUILTIN r1, r1, print_int

View file

@ -2382,7 +2382,7 @@ let validate_image (img : string) : string list =
let u64 o = if ok 8 o then String.get_int64_le img o else 0L in
let none = 0xFFFFFFFF in
if u32 0 <> 0x31424F57 then fail "bad magic";
if u32 4 <> 1 then fail "unsupported version";
if u32 4 <> 2 then fail "unsupported version";
let coff = u32 8 and ccnt = u32 12 in
let koff = u32 16 and kcnt = u32 20 in
let ioff = u32 24 and icnt = u32 28 in
@ -2423,6 +2423,18 @@ let validate_image (img : string) : string list =
if u8 (!o + j) > 5 then fail (Printf.sprintf "class %d field %d: bad kind" i j)
done;
o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4);
(* v2: three u32 arrays of per-field metadata — names (a Text constant or
"not recorded"), the referenced class id (or the json-raw marker), and
container element kinds. Mirrors runtime/src/loader.c's own checks. *)
for j = 0 to fcnt - 1 do
let nmk = u32 (!o + (j * 4)) in
if nmk <> 0xFFFFFFFF && not (text_const nmk) then
fail (Printf.sprintf "class %d field %d: bad name constant" i j);
let fc = u32 (!o + ((fcnt + j) * 4)) in
if fc <> 0xFFFFFFFF && fc <> 0xFFFFFFFE && fc >= kcnt then
fail (Printf.sprintf "class %d field %d: field class out of range" i j)
done;
o := !o + (fcnt * 12);
if !o > len then fail (Printf.sprintf "class %d: truncated" i)
done;
(* interfaces + vtable rows *)
@ -3294,7 +3306,7 @@ let () =
(count_substring ~needle:"Status.Pending flags" dump = 1
&& count_substring ~needle:"Status.Failed flags" dump = 1);
check "t4 table: Status.Failed's payload Text is a TEXT slot"
(count_substring ~needle:"Status.Failed flags=- fields=[TEXT]" dump = 1);
(count_substring ~needle:"Status.Failed flags=- fields=[reason:TEXT]" dump = 1);
check "t4 table: bare union gets no class entries"
(count_substring ~needle:"Kind" dump
- count_substring ~needle:"Kind" (String.concat "" [ "" ])
@ -3302,7 +3314,7 @@ let () =
&& count_substring ~needle:"Kind flags" dump = 0
&& count_substring ~needle:"Kind.Lo" dump = 0);
check "t4 table: a bare-union record field is a SCALAR slot, a payload one OWNED"
(count_substring ~needle:"Holder flags=- fields=[SCALAR, OWNED]" dump = 1)
(count_substring ~needle:"Holder flags=- fields=[k:SCALAR, st:OWNED]" dump = 1)
let () =
(* lowering shapes: variant_tag for a payload union's switch only; a

View file

@ -1,4 +1,4 @@
# The `.wob` format v1 — normative reference
# The `.wob` format v2 — normative reference
> Copied verbatim from the normative section of
> [`docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md`](../../superpowers/plans/2026-08-01-wob-format-and-vm-core.md)
@ -11,11 +11,19 @@
All integers little-endian; offsets are absolute file offsets.
**Header (44 bytes):** magic `"WOB1"`, version 1, then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none).
**Header (44 bytes):** magic `"WOB1"`, version 2, then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none).
**Constant pool** — sequential entries: one tag byte; tag 0 = i64 follows; tag 1 = text (u32 length + bytes, no NUL).
**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`), field count, then one kind byte per field padded to a 4-byte boundary. Field kinds: 0 SCALAR, 1 OWNED, 2 GCREF, 3 TEXT, 4 MULTI, 5 MAP. Runtime object layout: 16-byte header then one 8-byte slot per field, in declaration order.
**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order:
1. `field_names[i]` — constant index of the field's name, or all-ones for "not recorded" (what a hand-built test image writes).
2. `field_class[i]` — the class id the field refers to: its own class for an OWNED/GCREF field, its *element's* class for a container of records; `0xFFFFFFFE` marks a `json.Value` field, whose Text holds a raw JSON slice; all-ones for none.
3. `field_elem[i]` — a container field's element kinds: a MULTI's element kind, or a MAP's key kind in the low nibble and value kind in the next; 0 otherwise.
Field kinds: 0 SCALAR, 1 OWNED, 2 GCREF, 3 TEXT, 4 MULTI, 5 MAP. Runtime object layout: 16-byte header then one 8-byte slot per field, in declaration order.
The metadata exists for exactly one reason: `json.encode`/`json.decode` are runtime services driven by class metadata (`runtime/src/json.c`) rather than per-type generated code, so the names a JSON object needs and the shapes a decode has to build must live in the image. Every other part of the runtime ignores it.
**Interface section** — per interface: name constant index, method count. Global *slot ids* are assigned sequentially across interfaces in declaration order. Then a vtable row count and rows: class id, interface id, one method index per interface method.
@ -43,10 +51,18 @@ All integers little-endian; offsets are absolute file offsets.
| 29 | BUILTIN A B C | register A = builtin C applied to args starting at register B (fixed arity per builtin; `multi_new`/`map_new` carry kind immediates in B instead) |
| 30 | DB_STUB | trap T_DB "engine not linked" (spec: SQL-layer statements in milestone 1) |
| 31 | TRAP Bx | explicit trap with code Bx |
| 32 | TRY A sBx | push a catch frame for this frame and window: handler at pc + sBx, error record register A (haxe-parity Task 5) |
| 33 | ENDTRY | pop the innermost catch frame — the try region completed without trapping |
**Builtins:** now (ms), print (text), print_int, words (whitespace token count), multi_new/multi_push/multi_get/count/latest, map_new/map_set/map_get/map_has, int_to_text (haxe-parity Task 2), variant_tag (haxe-parity Task 4 — see "Enum payload variants" below).
**try/catch (Task 5).** A trap raised while a catch frame is live unwinds every frame *above* the catching one exactly as an uncaught trap does (drop maps run, registers null), then releases what the try region owned in the catching frame — the difference between the drop entry at the trapping instruction and the one at the handler pc — and resumes at the handler instead of leaving the VM. A frame that returns takes its still-open catch frames with it, so a `return` out of a try region cannot leave a handler pointing at a dead window. With no catch frame live, a trap behaves byte-for-byte as it did before v2. The catch arm's error record is an ordinary compiler-allocated object filled by the `err_fill` builtin (field order: 0 code, 1 line, 2 method, 3 msg).
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT.
**Builtins:** now (ms), print (text), print_int, words (whitespace token count), multi_new/multi_push/multi_get/count/latest, map_new/map_set/map_get/map_has, int_to_text (haxe-parity Task 2), variant_tag (haxe-parity Task 4 — see "Enum payload variants" below), err_fill (Task 5's catch record), then the systems stdlib:
- **text/containers** — len, byte_at, print_err, starts_with, ends_with, index_of, last_index_of, substr, trim, to_lower, char_of, parse_int, split, split_ws, join, slice, pop, shift, sort, reverse, remove, key_at, val_at, multi_set. Ids 16–39; `runtime/src/builtin.c`.
- **the OS half** — fs.exists/list/stat/read_all/read_at/append, time.sleep/local/iso, env.get/stopping, net.listen/accept/read/write/close, proc.run. Ids 40–56; `runtime/src/sysio.c`. A member that returns a record takes that record's **class id as its last argument**, so the VM allocates what it fills without knowing any source type name.
- **json** — encode (value + the value's static kind), decode (text + the class id to build). Ids 57–58; `runtime/src/json.c`. Decode yields the zero word on malformed input rather than trapping, which is what makes `json.decode(t) as T` a checked decode.
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT, IO (a syscall the source cannot prevent said no — errno's message rides in the error record).
## Enum payload variants (haxe-parity compiler Task 4)

View file

@ -30,15 +30,31 @@ maps to one `BUILTIN` id of the format doc.
| `set(m, k, v)` | `map_set` | 3 | insert or replace in a `map` |
| `has(m, k)` | `map_has` | 2 | `1`/`0` |
| `int_to_text(n)` | `int_to_text` | 1 | decimal rendering of an `Int`, as a fresh owned `Text` — haxe-parity Task 2's one fenced VM addition, the type-directed half of string interpolation (below); also directly callable |
| `len(x)` | `len` | 1 | byte length of a `Text`, or element/entry count of a container |
| `byte_at(t, i)` | `byte_at` | 2 | byte value at an index; out of range traps `BOUNDS` |
| `print_err(t)` | `print_err` | 1 | a `Text` to stderr, newline-terminated |
| `starts_with(t, p)` / `ends_with(t, s)` | same | 2 | `1`/`0` |
| `index_of(t, n)` / `last_index_of(t, n)` | same | 2 | first/last byte offset, `-1` when absent |
| `substr(t, start, len)` | `substr` | 3 | fresh `Text`, clamped (never traps) |
| `trim(t)` / `to_lower(t)` | same | 1 | fresh `Text` |
| `char_of(b)` | `char_of` | 1 | fresh one-byte `Text` |
| `parse_int(t)` | `parse_int` | 1 | `?Int` — an unparseable text is `0`, which is how `?Int` spells nil |
| `split(t, sep)` / `split_ws(t)` | same | 2 / 1 | fresh `multi Text` |
| `join(m, sep)` | `join` | 2 | fresh `Text` from a `multi Text` |
| `slice(m, from, to)` | `slice` | 3 | fresh `multi` over `[from, to)`; `Text` elements are COPIED, so slice and source never both own one value |
| `pop(m)` / `shift(m)` | same | 1 | removes and returns the last/first element (ownership moves to the caller); empty traps `BOUNDS` |
| `sort(m)` / `reverse(m)` | same | 1 | in place; `sort` compares `Text` by content, everything else as signed integers |
| `remove(m, k)` | `map_remove` | 2 | `1`/`0`; drops the removed key and value |
| `key_at(m, i)` / `val_at(m, i)` | same | 2 | slot-ordered map enumeration — what `for k, v in m` lowers onto |
| `m[i] = v` on a `multi` | `multi_set` | 3 | in-place element write, dropping the element it replaces |
`get`, `set`, `push`, `count` and `has` resolve on the container they are
given, so one source name covers the `multi` and `map` ids the runtime
keeps apart.
**Sugar.** `c[i]` is exactly `get(c, i)` and `m[k] = v` is exactly
`set(m, k, v)`. There is no element *write* into a `multi` — v1 has
`multi_push` and `multi_get` and no element store — so `m[i] = v` on a
`multi` is `WO-E403`.
`set(m, k, v)` for a `map` and `multi_set(m, i, v)` for a `multi` (the
element it replaces is the container's, so the VM drops it).
**Shadowing.** A user-declared free `fn` of the same name always wins. A
declared name is never silently replaced by a builtin.
@ -218,3 +234,64 @@ rejects `6`), and it needs none: every per-kind drop plan already ignores
a zero slot. `?T`'s field kind is therefore `T`'s. Note the consequence
for `@gc`: `?SomeGcClass` is a `GCREF` field like any other, so it
participates in refcounting and cycle detection normally.
## The systems stdlib's OS half (`fs`, `time`, `env`, `net`, `proc`)
Reserved module names resolve to one builtin per member
(`runtime/src/sysio.c`). Every one is a thin blocking libc call, so the
failure surface is uniform: a syscall that fails traps `WO_T_IO` carrying
errno's own message, and the source decides with `try ... catch` whether
that is fatal. Absence is never a trap — a missing path from `fs.stat` and an
unset `env.get` are nil.
| member | signature | notes |
| --- | --- | --- |
| `fs.exists(path)` | `-> Bool` | |
| `fs.list(dir)` | `-> multi Text` | names only, unsorted; unreadable dir traps `IO` |
| `fs.stat(path)` | `-> ?Stat` | `Stat { size, mtime (ms), inode, dir }` |
| `fs.read_all(path, cap)` | `-> Text` | truncated at `cap` |
| `fs.read_at(path, off, len)` | `-> Text` | short read allowed (a growing file is normal) |
| `fs.append(path, text)` | — | creates the file if absent |
| `time.now()` | `-> Int` | wall-clock ms; the existing `now` builtin |
| `time.sleep(ms)` | — | |
| `time.local(ms)` | `-> TimeParts` | `{ year, month, day, hour, minute, second, dow }`, dow 0 = Sunday |
| `time.iso(ms)` | `-> Text` | UTC, second precision |
| `env.get(name)` | `-> ?Text` | unset is nil |
| `env.stopping()` | `-> Bool` | SIGTERM/SIGINT latch, handlers installed on first use |
| `net.listen(host, port)` | `-> Int` | IPv4, SO_REUSEADDR, backlog 64; returns an fd |
| `net.accept(fd)` | `-> Int` | |
| `net.read(fd, max)` | `-> Text` | one read; the empty Text is EOF |
| `net.write(fd, text)` | — | writes all of it |
| `net.close(fd)` | — | |
| `proc.run(cmd, args)` | `-> ?Proc` | `Proc { code, out, err }`; stdout/stderr captured and capped |
`Stat`, `TimeParts` and `Proc` are **predeclared records**: no source declares
them, and their field ORDER is the contract with `sysio.c`, which writes them
by index. The compiler passes the record's class id as the member's last
argument, so the VM allocates what it fills.
## `json`
`json.encode(x) -> Text` and `json.decode(text) as T -> ?T`. Both are
metadata-driven (`runtime/src/json.c`): the class table's per-field names,
referenced classes and element kinds (`.wob` v2) are what let one
implementation encode and decode any record shape, with no per-type generated
code.
- `encode` takes the value's *static* kind alongside it, because a register
alone cannot say whether it holds an i64 or a pointer; everything below the
top level comes from object headers and the class table.
- `decode` parses and binds straight into the target class: keys are matched
against field names, a nested object is built as that field's class, an
array as a `multi` of that field's element kind, unknown keys are skipped,
and absent keys stay nil. Malformed input yields nil — never a trap, which
is what makes the `as` form a *checked* decode. `as` exists for no other
purpose: there is no reinterpret cast in the doctrine.
- `json.Value` is a reserved type name for a decoded value the source does not
inspect: it holds the raw JSON slice it came from (kind TEXT) and encodes
back verbatim.
Two documented limits: a `Bool` field is a SCALAR slot like any other integer,
so it encodes as `0`/`1` rather than `false`/`true` (the kind byte does not
distinguish them); and a JSON number with a fraction or exponent decodes by
truncation to `Int`, since the language has no float.

View file

@ -63,7 +63,8 @@ static int elem_cmp(uint8_t kind, uint64_t a, uint64_t b) {
int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
wo_rt *rt = &vm->rt;
uint8_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins);
/* the OS half lives in its own translation unit — see sysio.c */
/* the OS half and json live in their own translation units */
if (C >= WO_B_JSON_ENCODE) return wo_builtin_json(vm, R, ins, msg);
if (C >= WO_B_SYS_FIRST) return wo_builtin_sys(vm, R, ins, msg);
switch (C) {
case WO_B_NOW: { /* wall-clock milliseconds */

View file

@ -14,4 +14,7 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
* wo_builtin dispatches every id at or above WO_B_SYS_FIRST here. */
int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
/* json.encode / json.decode (runtime/src/json.c), same contract again. */
int wo_builtin_json(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
#endif /* WO_BUILTIN_H */

585
runtime/src/json.c Normal file
View file

@ -0,0 +1,585 @@
/* json.c — `json.encode` and `json.decode`, driven by class-table metadata
* instead of per-type generated code.
*
* The .wob class table carries, per field, its NAME, the CLASS it refers to
* and a container field's ELEMENT KINDS (wob.h's "class-table field
* metadata", format v2). That is everything both directions need:
*
* encode the top-level value's kind comes from the compiler (a register
* alone cannot say whether it holds an i64 or a pointer);
* everything below it comes from object headers and the class
* table, so a nested record needs no static knowledge at all.
* decode parse-and-bind straight into the target class: an object's keys
* are matched against field names, each value converted to that
* field's kind, a nested object built as that field's class, an
* array built as a `multi` of that field's element kind. Unknown
* keys are skipped; absent keys stay the zero word, which is how
* `?T` spells nil. Malformed input yields nil, never a trap —
* that is what makes `json.decode(t) as T` a *checked* decode.
*
* Two deliberate, documented limits: a `Bool` field is a WO_K_SCALAR slot
* like every other integer, so it encodes as 0/1 rather than false/true (the
* kind byte does not distinguish them); and a JSON number with a fraction or
* an exponent decodes by truncation to i64, since the language has no float.
* A `json.Value` field (field_class == WOB_FIELD_JSON_RAW) holds the raw JSON
* slice it was decoded from, and encodes back verbatim.
*/
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "builtin.h"
#include "cont.h"
#include "gc.h"
/* ---- growable output buffer (encode) --------------------------------- */
typedef struct {
char *p;
size_t len, cap;
int oom;
} jbuf;
static void jb_reserve(jbuf *b, size_t extra) {
if (b->oom) return;
if (b->len + extra <= b->cap) return;
size_t want = b->cap ? b->cap * 2 : 256;
while (want < b->len + extra) want *= 2;
char *np = realloc(b->p, want);
if (!np) {
b->oom = 1;
return;
}
b->p = np;
b->cap = want;
}
static void jb_put(jbuf *b, const char *s, size_t n) {
jb_reserve(b, n);
if (b->oom) return;
memcpy(b->p + b->len, s, n);
b->len += n;
}
static void jb_ch(jbuf *b, char c) { jb_put(b, &c, 1); }
static void jb_int(jbuf *b, int64_t v) {
char tmp[24];
int n = snprintf(tmp, sizeof tmp, "%lld", (long long)v);
jb_put(b, tmp, (size_t)n);
}
/* JSON string body: quotes, backslashes and control bytes escaped; every
* other byte passes through, so UTF-8 stays UTF-8. */
static void jb_text(jbuf *b, const wo_str *s) {
jb_ch(b, '"');
for (uint32_t i = 0; i < s->len; i++) {
unsigned char c = (unsigned char)s->data[i];
switch (c) {
case '"': jb_put(b, "\\\"", 2); break;
case '\\': jb_put(b, "\\\\", 2); break;
case '\n': jb_put(b, "\\n", 2); break;
case '\r': jb_put(b, "\\r", 2); break;
case '\t': jb_put(b, "\\t", 2); break;
case '\b': jb_put(b, "\\b", 2); break;
case '\f': jb_put(b, "\\f", 2); break;
default:
if (c < 0x20) {
char esc[7];
int n = snprintf(esc, sizeof esc, "\\u%04x", c);
jb_put(b, esc, (size_t)n);
} else
jb_ch(b, (char)c);
}
}
jb_ch(b, '"');
}
/* ---- encode ---------------------------------------------------------- */
static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, uint32_t fclass);
static void enc_object(jbuf *b, const wo_module *mod, const wo_hdr *o) {
const wo_classdesc *c = &mod->classes[o->class_id];
const uint64_t *fs = wo_fields((wo_hdr *)(uintptr_t)o);
jb_ch(b, '{');
int first = 1;
for (uint32_t i = 0; i < c->field_cnt; i++) {
uint32_t nm = c->field_names ? c->field_names[i] : WOB_NONE;
if (nm == WOB_NONE || nm >= mod->const_cnt) continue; /* unnamed: not encodable */
if (!first) jb_ch(b, ',');
first = 0;
jb_text(b, mod->consts[nm].s);
jb_ch(b, ':');
enc_value(b, mod, fs[i], c->kinds[i], c->field_class ? c->field_class[i] : WOB_NONE);
}
jb_ch(b, '}');
}
static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, uint32_t fclass) {
if (!v && kind != WO_K_SCALAR) {
jb_put(b, "null", 4);
return;
}
switch (kind) {
case WO_K_SCALAR:
jb_int(b, (int64_t)v);
return;
case WO_K_TEXT: {
const wo_str *s = (const wo_str *)(uintptr_t)v;
if (fclass == WOB_FIELD_JSON_RAW) jb_put(b, s->data, s->len); /* already JSON */
else jb_text(b, s);
return;
}
case WO_K_MULTI: {
const wo_multi *m = (const wo_multi *)(uintptr_t)v;
jb_ch(b, '[');
for (uint32_t i = 0; i < m->len; i++) {
if (i) jb_ch(b, ',');
enc_value(b, mod, m->items[i], m->elem_kind, fclass);
}
jb_ch(b, ']');
return;
}
case WO_K_MAP: {
const wo_map *m = (const wo_map *)(uintptr_t)v;
jb_ch(b, '{');
for (uint32_t i = 0; i < m->len; i++) {
if (i) jb_ch(b, ',');
if (m->key_kind == WO_K_TEXT && m->keys[i]) jb_text(b, (const wo_str *)(uintptr_t)m->keys[i]);
else {
/* a non-Text key still has to be a JSON string */
jb_ch(b, '"');
jb_int(b, (int64_t)m->keys[i]);
jb_ch(b, '"');
}
jb_ch(b, ':');
enc_value(b, mod, m->vals[i], m->val_kind, fclass);
}
jb_ch(b, '}');
return;
}
default: { /* OWNED / GCREF: a class object, or a native one */
const wo_hdr *o = (const wo_hdr *)(uintptr_t)v;
if (o->class_id == WO_CLS_STR) {
jb_text(b, (const wo_str *)(uintptr_t)o);
return;
}
if (o->class_id == WO_CLS_MULTI) {
enc_value(b, mod, v, WO_K_MULTI, fclass);
return;
}
if (o->class_id == WO_CLS_MAP) {
enc_value(b, mod, v, WO_K_MAP, fclass);
return;
}
if (o->class_id < mod->class_cnt) {
enc_object(b, mod, o);
return;
}
jb_put(b, "null", 4);
return;
}
}
}
/* ---- decode: parse and bind ------------------------------------------ */
typedef struct {
const char *p, *end;
wo_rt *rt;
const wo_module *mod;
} jp;
static void jskip_ws(jp *j) {
while (j->p < j->end && (*j->p == ' ' || *j->p == '\t' || *j->p == '\n' || *j->p == '\r')) j->p++;
}
static int jparse_value(jp *j, uint8_t kind, uint32_t fclass, uint32_t felem, uint64_t *out);
/* Walks one value without building anything — an unknown object key, or a
* value whose JSON shape does not fit the field it landed on. */
static int jskip_value(jp *j) {
jskip_ws(j);
if (j->p >= j->end) return -1;
char c = *j->p;
if (c == '{' || c == '[') {
char close = c == '{' ? '}' : ']';
int depth = 0;
while (j->p < j->end) {
char d = *j->p++;
if (d == '"') { /* strings may contain braces */
while (j->p < j->end && *j->p != '"') {
if (*j->p == '\\' && j->p + 1 < j->end) j->p++;
j->p++;
}
if (j->p < j->end) j->p++;
continue;
}
if (d == '{' || d == '[') depth++;
else if (d == '}' || d == ']') {
depth--;
if (depth == 0) return 0;
}
}
(void)close;
return -1;
}
if (c == '"') {
j->p++;
while (j->p < j->end && *j->p != '"') {
if (*j->p == '\\' && j->p + 1 < j->end) j->p++;
j->p++;
}
if (j->p >= j->end) return -1;
j->p++;
return 0;
}
while (j->p < j->end && *j->p != ',' && *j->p != '}' && *j->p != ']' &&
*j->p != ' ' && *j->p != '\n' && *j->p != '\t' && *j->p != '\r')
j->p++;
return 0;
}
/* A JSON string into a fresh Text, applying escapes. \uXXXX becomes UTF-8
* (BMP only: a surrogate pair decodes as two replacement-free code units,
* which is what every byte-oriented consumer here wants). */
static wo_str *jparse_string(jp *j) {
if (j->p >= j->end || *j->p != '"') return NULL;
const char *start = ++j->p;
size_t worst = (size_t)(j->end - start);
wo_str *s = wo_str_alloc(j->rt, (uint32_t)worst);
if (!s) return NULL;
uint32_t n = 0;
while (j->p < j->end && *j->p != '"') {
char c = *j->p++;
if (c != '\\') {
s->data[n++] = c;
continue;
}
if (j->p >= j->end) break;
char e = *j->p++;
switch (e) {
case 'n': s->data[n++] = '\n'; break;
case 't': s->data[n++] = '\t'; break;
case 'r': s->data[n++] = '\r'; break;
case 'b': s->data[n++] = '\b'; break;
case 'f': s->data[n++] = '\f'; break;
case 'u': {
unsigned cp = 0;
for (int k = 0; k < 4 && j->p < j->end; k++) {
char h = *j->p++;
unsigned d = (unsigned)(h >= '0' && h <= '9' ? h - '0'
: h >= 'a' && h <= 'f' ? h - 'a' + 10
: h >= 'A' && h <= 'F' ? h - 'A' + 10
: 0);
cp = cp * 16 + d;
}
if (cp < 0x80) s->data[n++] = (char)cp;
else if (cp < 0x800) {
s->data[n++] = (char)(0xC0 | (cp >> 6));
s->data[n++] = (char)(0x80 | (cp & 0x3F));
} else {
s->data[n++] = (char)(0xE0 | (cp >> 12));
s->data[n++] = (char)(0x80 | ((cp >> 6) & 0x3F));
s->data[n++] = (char)(0x80 | (cp & 0x3F));
}
break;
}
default: s->data[n++] = e; /* covers \" \\ \/ */
}
}
if (j->p >= j->end) {
wo_str_free(j->rt, s);
return NULL;
}
j->p++; /* closing quote */
s->len = n;
return s;
}
/* An object into a fresh instance of [class_id]: keys matched against the
* class's field names, values converted to each field's own kind. */
static int jparse_object(jp *j, uint32_t class_id, uint64_t *out) {
const wo_classdesc *c = &j->mod->classes[class_id];
wo_hdr *o = wo_obj_new(j->rt, class_id);
if (!o) return -1;
uint64_t *fs = wo_fields(o);
jskip_ws(j);
if (j->p >= j->end || *j->p != '{') {
wo_drop_obj(j->rt, o);
return -1;
}
j->p++;
jskip_ws(j);
if (j->p < j->end && *j->p == '}') {
j->p++;
*out = (uint64_t)(uintptr_t)o;
return 0;
}
for (;;) {
jskip_ws(j);
wo_str *key = jparse_string(j);
if (!key) {
wo_drop_obj(j->rt, o);
return -1;
}
jskip_ws(j);
if (j->p >= j->end || *j->p != ':') {
wo_str_free(j->rt, key);
wo_drop_obj(j->rt, o);
return -1;
}
j->p++;
/* which field is this key? */
uint32_t idx = c->field_cnt;
for (uint32_t i = 0; i < c->field_cnt; i++) {
uint32_t nm = c->field_names ? c->field_names[i] : WOB_NONE;
if (nm == WOB_NONE || nm >= j->mod->const_cnt) continue;
const wo_str *fname = j->mod->consts[nm].s;
if (fname->len == key->len && !memcmp(fname->data, key->data, key->len)) {
idx = i;
break;
}
}
wo_str_free(j->rt, key);
if (idx == c->field_cnt) {
if (jskip_value(j) != 0) {
wo_drop_obj(j->rt, o);
return -1;
}
} else {
uint64_t val = 0;
if (jparse_value(j, c->kinds[idx], c->field_class ? c->field_class[idx] : WOB_NONE,
c->field_elem ? c->field_elem[idx] : 0, &val) != 0) {
wo_drop_obj(j->rt, o);
return -1;
}
fs[idx] = val;
}
jskip_ws(j);
if (j->p < j->end && *j->p == ',') {
j->p++;
continue;
}
if (j->p < j->end && *j->p == '}') {
j->p++;
*out = (uint64_t)(uintptr_t)o;
return 0;
}
wo_drop_obj(j->rt, o);
return -1;
}
}
static int jparse_value(jp *j, uint8_t kind, uint32_t fclass, uint32_t felem, uint64_t *out) {
jskip_ws(j);
if (j->p >= j->end) return -1;
/* a `json.Value` field keeps the raw slice, whatever shape it is */
if (fclass == WOB_FIELD_JSON_RAW) {
const char *start = j->p;
if (jskip_value(j) != 0) return -1;
wo_str *raw = wo_str_new(j->rt, start, (uint32_t)(j->p - start));
if (!raw) return -1;
*out = (uint64_t)(uintptr_t)raw;
return 0;
}
char c = *j->p;
if (c == 'n') { /* null: the zero word, for every kind */
return jskip_value(j) == 0 ? (*out = 0, 0) : -1;
}
if (c == '{') {
if ((kind == WO_K_OWNED || kind == WO_K_GCREF) && fclass < j->mod->class_cnt)
return jparse_object(j, fclass, out);
if (kind == WO_K_MAP) {
uint8_t kk = (uint8_t)(felem & 0x0F), vk = (uint8_t)((felem >> 4) & 0x0F);
wo_map *m = wo_map_new(j->rt, kk, vk);
if (!m) return -1;
j->p++;
jskip_ws(j);
if (j->p < j->end && *j->p == '}') {
j->p++;
*out = (uint64_t)(uintptr_t)m;
return 0;
}
for (;;) {
jskip_ws(j);
wo_str *key = jparse_string(j);
if (!key) {
wo_drop_obj(j->rt, &m->h);
return -1;
}
jskip_ws(j);
if (j->p >= j->end || *j->p != ':') {
wo_str_free(j->rt, key);
wo_drop_obj(j->rt, &m->h);
return -1;
}
j->p++;
uint64_t val = 0;
if (jparse_value(j, vk, fclass, 0, &val) != 0) {
wo_str_free(j->rt, key);
wo_drop_obj(j->rt, &m->h);
return -1;
}
uint64_t old = 0;
if (wo_map_set(m, (uint64_t)(uintptr_t)key, val, &old) < 0) {
wo_str_free(j->rt, key);
wo_drop_obj(j->rt, &m->h);
return -1;
}
jskip_ws(j);
if (j->p < j->end && *j->p == ',') {
j->p++;
continue;
}
if (j->p < j->end && *j->p == '}') {
j->p++;
*out = (uint64_t)(uintptr_t)m;
return 0;
}
wo_drop_obj(j->rt, &m->h);
return -1;
}
}
/* an object where the field wants something else: skip it, leave nil */
*out = 0;
return jskip_value(j);
}
if (c == '[') {
if (kind != WO_K_MULTI) {
*out = 0;
return jskip_value(j);
}
uint8_t ek = (uint8_t)(felem & 0x0F);
wo_multi *m = wo_multi_new(j->rt, ek);
if (!m) return -1;
j->p++;
jskip_ws(j);
if (j->p < j->end && *j->p == ']') {
j->p++;
*out = (uint64_t)(uintptr_t)m;
return 0;
}
for (;;) {
uint64_t item = 0;
if (jparse_value(j, ek, fclass, 0, &item) != 0 || wo_multi_push(m, item) != 0) {
wo_drop_obj(j->rt, &m->h);
return -1;
}
jskip_ws(j);
if (j->p < j->end && *j->p == ',') {
j->p++;
continue;
}
if (j->p < j->end && *j->p == ']') {
j->p++;
*out = (uint64_t)(uintptr_t)m;
return 0;
}
wo_drop_obj(j->rt, &m->h);
return -1;
}
}
if (c == '"') {
wo_str *s = jparse_string(j);
if (!s) return -1;
if (kind == WO_K_TEXT) {
*out = (uint64_t)(uintptr_t)s;
return 0;
}
wo_str_free(j->rt, s); /* a string where a number was declared: nil */
*out = 0;
return 0;
}
if (c == 't' || c == 'f') {
int truth = c == 't';
if (jskip_value(j) != 0) return -1;
*out = kind == WO_K_SCALAR ? (uint64_t)truth : 0;
return 0;
}
/* number: i64 by truncation — the language has no float */
{
int neg = 0;
if (*j->p == '-') {
neg = 1;
j->p++;
} else if (*j->p == '+')
j->p++;
int64_t acc = 0;
int digits = 0;
while (j->p < j->end && *j->p >= '0' && *j->p <= '9') {
acc = acc * 10 + (*j->p++ - '0');
digits++;
}
if (!digits) return -1;
if (j->p < j->end && (*j->p == '.' || *j->p == 'e' || *j->p == 'E')) {
/* consume the fraction/exponent; the integer part is the value */
if (*j->p == '.') {
j->p++;
while (j->p < j->end && *j->p >= '0' && *j->p <= '9') j->p++;
}
if (j->p < j->end && (*j->p == 'e' || *j->p == 'E')) {
j->p++;
if (j->p < j->end && (*j->p == '-' || *j->p == '+')) j->p++;
while (j->p < j->end && *j->p >= '0' && *j->p <= '9') j->p++;
}
}
*out = kind == WO_K_SCALAR ? (uint64_t)(neg ? -acc : acc) : 0;
return 0;
}
}
int wo_builtin_json(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
wo_rt *rt = &vm->rt;
uint8_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins);
switch (C) {
case WO_B_JSON_ENCODE: {
jbuf b = {NULL, 0, 0, 0};
uint8_t kind = (uint8_t)R[B + 1];
enc_value(&b, vm->mod, R[B], kind, WOB_NONE);
if (b.oom) {
free(b.p);
*msg = "out of memory";
return WO_T_OOM;
}
wo_str *s = wo_str_new(rt, b.p ? b.p : "", (uint32_t)b.len);
free(b.p);
if (!s) {
*msg = "out of memory";
return WO_T_OOM;
}
R[A] = (uint64_t)(uintptr_t)s;
return 0;
}
case WO_B_JSON_DECODE: { /* malformed input is nil, never a trap */
if (!R[B]) {
R[A] = 0;
return 0;
}
const wo_str *src = (const wo_str *)(uintptr_t)R[B];
if (src->h.class_id != WO_CLS_STR) {
*msg = "not a text value";
return WO_T_BOUNDS;
}
uint64_t cls = R[B + 1];
if (cls >= vm->mod->class_cnt) {
*msg = "decode target is not a class";
return WO_T_BOUNDS;
}
jp j = {src->data, src->data + src->len, rt, vm->mod};
uint64_t out = 0;
if (jparse_object(&j, (uint32_t)cls, &out) != 0) {
R[A] = 0;
return 0;
}
R[A] = out;
return 0;
}
default:
*msg = "unknown json builtin";
return WO_T_EXPLICIT;
}
}

View file

@ -59,6 +59,9 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
[WO_B_ENV_GET] = 1, [WO_B_ENV_STOPPING] = 0, [WO_B_NET_LISTEN] = 2,
[WO_B_NET_ACCEPT] = 1, [WO_B_NET_READ] = 2, [WO_B_NET_WRITE] = 2,
[WO_B_NET_CLOSE] = 1, [WO_B_PROC_RUN] = 3,
/* json (json.c): encode takes the value's static kind, decode the class
id to build */
[WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2,
};
static int vtab_cmp(const void *a, const void *b) {
@ -137,7 +140,7 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
m->classes = calloc(kcnt, sizeof(wo_classdesc));
if (!m->classes) BAIL("out of memory");
}
size_t pool_len = 0;
size_t pool_len = 0, meta_pool = 0;
for (uint32_t i = 0; i < kcnt; i++) {
uint32_t name, flags, fcnt;
if (rd_u32(&k, &name) || rd_u32(&k, &flags) || rd_u32(&k, &fcnt))
@ -159,15 +162,47 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
uint32_t pad = (4u - fcnt % 4u) % 4u;
if (pad > k.len - k.off) BAIL("class %u: truncated pad", (unsigned)i);
k.off += pad;
/* v2: three u32 arrays of per-field metadata (names, referenced
class ids, container element kinds) — wob.h's "class-table field
metadata" note. A name of WOB_NONE means "not recorded", which is
what a hand-built test image writes; any other value must be a
real Text constant, since json.encode renders it as a key. */
size_t meta_words = (size_t)fcnt * 3u;
if (meta_words * 4u > k.len - k.off) BAIL("class %u: truncated field metadata", (unsigned)i);
uint32_t *mp = realloc(m->metapool, (meta_pool + (meta_words ? meta_words : 1)) * sizeof(uint32_t));
if (!mp) BAIL("out of memory");
m->metapool = mp;
for (size_t w = 0; w < meta_words; w++) {
uint32_t val;
if (rd_u32(&k, &val)) BAIL("class %u: truncated field metadata", (unsigned)i);
m->metapool[meta_pool + w] = val;
}
for (uint32_t j = 0; j < fcnt; j++) {
uint32_t nm = m->metapool[meta_pool + j];
if (nm != WOB_NONE && (nm >= m->const_cnt || m->consts[nm].tag != WOB_K_TEXT))
BAIL("class %u field %u: bad name constant", (unsigned)i, (unsigned)j);
uint32_t fc = m->metapool[meta_pool + fcnt + j];
if (fc != WOB_NONE && fc != WOB_FIELD_JSON_RAW && fc >= kcnt)
BAIL("class %u field %u: field class out of range", (unsigned)i, (unsigned)j);
}
m->classes[i].name = name;
m->classes[i].flags = flags;
m->classes[i].field_cnt = fcnt;
m->classes[i].kinds = (const uint8_t *)(uintptr_t)pool_len; /* offset */
/* offsets too; fixed up to pointers once the pool stops moving */
m->classes[i].field_names = (const uint32_t *)(uintptr_t)meta_pool;
m->classes[i].field_class = (const uint32_t *)(uintptr_t)(meta_pool + fcnt);
m->classes[i].field_elem = (const uint32_t *)(uintptr_t)(meta_pool + 2u * (size_t)fcnt);
pool_len += fcnt ? fcnt : 1;
meta_pool += meta_words ? meta_words : 1;
m->class_cnt = i + 1;
}
for (uint32_t i = 0; i < m->class_cnt; i++)
for (uint32_t i = 0; i < m->class_cnt; i++) {
m->classes[i].kinds = m->kindpool + (uintptr_t)m->classes[i].kinds;
m->classes[i].field_names = m->metapool + (uintptr_t)m->classes[i].field_names;
m->classes[i].field_class = m->metapool + (uintptr_t)m->classes[i].field_class;
m->classes[i].field_elem = m->metapool + (uintptr_t)m->classes[i].field_elem;
}
/* ---- interfaces + vtable rows (expanded to sorted triples) ---- */
cur_t s = {buf, len, ioff};
@ -485,6 +520,7 @@ void wo_module_free(wo_module *m) {
free(m->consts);
free(m->classes);
free(m->kindpool);
free(m->metapool);
free(m->vtabs);
for (uint32_t i = 0; i < m->method_cnt; i++) {
free(m->methods[i].code);

View file

@ -51,6 +51,9 @@ typedef struct wo_module {
wo_classdesc *classes;
uint32_t class_cnt;
uint8_t *kindpool; /* pooled field-kind bytes the classes point into */
/* pooled per-field metadata (v2): names, referenced class ids, element
kinds — see wob.h's "class-table field metadata" note */
uint32_t *metapool;
uint32_t slot_cnt; /* total interface slots across all interfaces */
wo_vtabent *vtabs;
uint32_t vtab_cnt;

View file

@ -12,7 +12,7 @@
/* ---- file header (44 bytes, absolute offsets) ---- */
#define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */
#define WOB_VERSION 1u
#define WOB_VERSION 2u /* v2 adds per-field names/types to the class table */
#define WOB_HDR_SIZE 44u
#define WOB_OFF_MAGIC 0u
#define WOB_OFF_VERSION 4u
@ -23,6 +23,24 @@
#define WOB_OFF_ENTRY 40u
#define WOB_NONE 0xFFFFFFFFu /* "no entry method" / "free fn" class id */
/* ---- class-table field metadata (v2) ----
* Every class row carries, after its kind bytes, three u32 arrays — one
* entry per field: the constant index of the field's NAME, the class id the
* field REFERS to, and the element kinds of a container field. They exist
* for one reason: `json.encode`/`json.decode` are runtime services driven by
* class metadata (runtime/src/json.c) instead of per-type generated code, so
* the names a JSON object needs and the shapes a decode must build have to
* be in the image. Absent metadata is WOB_NONE / 0, which every other part
* of the runtime ignores.
*
* field_class[i]: the class id of an OWNED/GCREF field, or of a container
* field's element when that element is a class; WOB_FIELD_JSON_RAW marks a
* `json.Value` field, whose Text holds raw JSON that encode emits verbatim
* and decode captures unparsed; WOB_NONE otherwise.
* field_elem[i]: for a MULTI field, its element kind; for a MAP field, the
* key kind in the low byte and the value kind in the next; 0 otherwise. */
#define WOB_FIELD_JSON_RAW 0xFFFFFFFEu
/* ---- constant pool tags ---- */
#define WOB_K_INT 0u /* tag byte, then i64 */
#define WOB_K_TEXT 1u /* tag byte, then u32 len + bytes (no NUL) */
@ -235,8 +253,16 @@ enum {
WO_B_NET_WRITE = 54, /* (fd, text) -> 0 */
WO_B_NET_CLOSE = 55, /* (fd) -> 0 */
WO_B_PROC_RUN = 56, /* (cmd, multi Text args, cls) -> Proc {code, out, err} */
/* ---- json (runtime/src/json.c): metadata-driven, not per-type code.
* encode takes the STATIC kind of its argument, because a register alone
* cannot say whether it holds an i64 or a pointer; everything below the
* top level comes from object headers and the class table. decode takes
* the class id to build, and yields nil (0) on malformed input — never a
* trap, which is what makes `json.decode(t) as T` a checked decode. ---- */
WO_B_JSON_ENCODE = 57, /* (value, kind) -> Text */
WO_B_JSON_DECODE = 58, /* (text, cls) -> ?instance of cls */
};
#define WO_B_MAX 56u
#define WO_B_MAX 58u
/* ids at or above this one live in sysio.c, not builtin.c */
#define WO_B_SYS_FIRST WO_B_FS_EXISTS
@ -264,6 +290,12 @@ typedef struct wo_classdesc {
uint32_t flags; /* bit0: instances are @gc */
uint32_t field_cnt;
const uint8_t *kinds; /* field_cnt kind bytes, declaration order */
/* v2 per-field metadata, field_cnt entries each — see the
* "class-table field metadata" note above. Both may be NULL for a
* class an image wrote with no metadata at all. */
const uint32_t *field_names; /* constant index of each field's name */
const uint32_t *field_class; /* referenced class id / JSON_RAW / NONE */
const uint32_t *field_elem; /* container element kinds */
} wo_classdesc;
#define WO_CLASSF_GC 0x01u

View file

@ -59,6 +59,12 @@ uint32_t wb_class(wb_t *b, uint32_t name_const, uint32_t flags,
put_u32(&b->classes, field_cnt);
put(&b->classes, kinds, field_cnt);
for (uint32_t pad = field_cnt; pad % 4; pad++) put_u8(&b->classes, 0);
/* v2 per-field metadata (wob.h): a hand-built image records no field
names and no referenced classes — WOB_NONE reads as "not recorded",
which every consumer but json.encode/decode ignores. */
for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, WOB_NONE);
for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, WOB_NONE);
for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, 0);
return b->class_cnt++;
}