198 lines
12 KiB
Markdown
198 lines
12 KiB
Markdown
# Trace / compare log format (v1)
|
||
|
||
Wire format between the shim (`sots-engine/src/shim/`, C++, no JSON library) and the
|
||
harness in this directory (`tracecmp.py`, stdlib Python). One file per shim run; golden
|
||
traces live in `verify/traces/`, harness reports in `verify/results/compare/`.
|
||
|
||
Design goals, in order: (1) trivially emittable from C++ with `fprintf`; (2) every value
|
||
carries its C type so the harness can apply the right equality rule; (3) big memory
|
||
regions never blow up the log (hash, summarize, cap); (4) the same record shape serves
|
||
online `compare` (shim runs original + ours) and offline `replay` (harness diffs a golden
|
||
`trace` log against a host-side implementation's output).
|
||
|
||
## 1. Container: JSON Lines
|
||
|
||
- UTF-8, no BOM. One JSON object per line, terminated by `\n` (LF only). No blank lines,
|
||
no comments, no trailing commas. A line that does not parse is an **invalid record**.
|
||
- The first line SHOULD be a `meta` record (section 5). Everything else is a **call
|
||
record** (section 2).
|
||
- Records are appended in emission order; `call_id` (not line order) is the identity.
|
||
- Log files are named `<run-tag>.jsonl` (e.g. `verify/traces/m1-cfgvar-20260907.jsonl`).
|
||
|
||
## 2. Call record
|
||
|
||
```json
|
||
{"ts":123456,"hook":"CfgVar_RegisterKey","mode":"trace","call_id":17,"thread":4120,
|
||
"args":[{"t":"str","v":"ForceSingleCore"},{"t":"i32","v":1}],
|
||
"ret":{"t":"bool","v":true},
|
||
"side":{"cfg_table":{"before":{"t":"bytes","n":4096,"sha256":"…"},
|
||
"after":{"t":"bytes","n":4096,"sha256":"…"}}}}
|
||
```
|
||
|
||
| field | type | required | meaning |
|
||
|---|---|---|---|
|
||
| `ts` | int | yes | microseconds since shim init (QPC-based). Informational; never compared. |
|
||
| `hook` | string | yes | hook name — the function's recovered name (`Game::…` / `Mars::…` or `FUN_xxxxxxxx`). |
|
||
| `mode` | `"trace"` \| `"compare"` \| `"replace"` | yes | which mode the hook ran in for this call. |
|
||
| `call_id` | int ≥ 0 | yes | process-global, monotonically increasing, atomic. Unique within a log. |
|
||
| `thread` | int | yes | OS thread id. Informational. |
|
||
| `depth` | int | no | re-entrancy depth (0 = outermost). Informational. |
|
||
| `args` | array of typed values | yes | inputs, in declaration order. `n` names them when known. Never compared (they are the snapshot both sides ran on). |
|
||
| `ret` | typed value or `null` | yes | return value of the **original** (trace/compare) or of **ours** (replace). `null` = void. |
|
||
| `side` | object | yes (may be `{}`) | declared side effects: `name -> {"before": tv|null, "after": tv}`. `after` is compared; `before` is informational (a mismatch in `before` between original and ours would be a snapshot bug and is reported as a warning, not a divergence). |
|
||
| `ours` | object | compare only | `{"ret": tv|null, "side": {name: {"after": tv}}}` — the reimplementation's outputs on the same snapshot. |
|
||
| `diverged` | bool | compare only | the shim's own verdict. The harness recomputes and reports disagreements as warnings. |
|
||
| `diff` | array of diff entries | compare only | the shim's own diff (may be `[]` or truncated). Advisory. |
|
||
| `err` | string | no | the hook could not capture (exception in ours, snapshot failure…). Counts as a divergence in `compare`. |
|
||
| `note` | string | no | free text. Ignored. |
|
||
|
||
Unknown top-level keys are a validation **warning** (not an error) so the shim can add
|
||
fields without breaking older harnesses.
|
||
|
||
`replace` records have no `ours`; `ret`/`side` are the reimplementation's. They are
|
||
counted and validated but never diffed (there is nothing to diff against).
|
||
|
||
## 3. Typed values (`tv`)
|
||
|
||
Every value is an object `{"t": TYPE, "v": VALUE}` plus optional `"n": "name"`.
|
||
TYPEs and their equality rule:
|
||
|
||
| `t` | `v` | equality |
|
||
|---|---|---|
|
||
| `bool` | `true`/`false` | exact |
|
||
| `i8 i16 i32 i64 u8 u16 u32 u64` | JSON integer. i64/u64 above 2^53 MUST be a decimal **string** (`"v":"18446744073709551615"`) — the harness accepts both int and string. | exact |
|
||
| `f32`, `f64` | JSON number, or the string `"nan"`, `"inf"`, `"-inf"`. Emit with `%.9g` (f32) / `%.17g` (f64). | tolerant (section 6) |
|
||
| `str` | JSON string carrying **raw bytes** of a `char*`/`std::string` (cp1252 in this engine). Each byte maps to one code point U+0000–U+00FF (see escaping, section 4). | exact |
|
||
| `wstr` | JSON string of a `wchar_t*` (UTF-16 code units → `\uXXXX`). | exact |
|
||
| `ptr` | hex string `"0x00a36fd0"` | **ignored** by default (ours allocates elsewhere). A hook policy may set `"ptr":"exact"`. |
|
||
| `enum` | integer, optional `"name"` sibling with the symbolic name | exact on the integer |
|
||
| `null` | `null` | exact (only equals `null`) |
|
||
| `bytes` | see below | exact on `sha256` (and on `hex` when both inline) |
|
||
| `list` | JSON array of tv | same length, element-wise, ordered |
|
||
| `set` | JSON array of tv | unordered multiset: sort both by canonical text, then element-wise |
|
||
| `struct` | JSON object `{field: tv}` | same key set, field-wise (recursion) |
|
||
| `json` | any canonical-JSON value (section 7) — used by the oracle bridge and host-side tests | structural exact, with the hook's float tolerance applied to JSON numbers that are floats on **both** sides |
|
||
|
||
A type mismatch between the two sides (`i32` vs `u32`, `str` vs `wstr`) is a divergence
|
||
(`why: "type"`). Type mismatch does not apply inside `json` values, where int vs float
|
||
is a divergence unless both are numbers and the tolerance policy is `"numeric"`.
|
||
|
||
### `bytes` — binary blobs and memory regions
|
||
|
||
```json
|
||
{"t":"bytes","n":4096,"sha256":"<64 hex>","hex":"<2n hex>"}
|
||
{"t":"bytes","n":1048576,"sha256":"<64 hex>","head":"<first 32 bytes hex>"}
|
||
```
|
||
|
||
- `n` (length in bytes) and `sha256` (lowercase hex of the SHA-256 of the raw bytes) are
|
||
**always** present, even for `n = 0` (sha256 of the empty string).
|
||
- `hex` (lowercase, no separators) is present when `n <= inline_max`. `inline_max` is a
|
||
shim setting recorded in `meta` (default **256**). `hex` is the only inline encoding;
|
||
base64 is not used (harder to eyeball, no size win worth a second decoder).
|
||
- Larger blobs carry `head` (first 32 bytes, hex) for eyeballing only.
|
||
- When both sides are inline and differ, the harness reports the first differing byte
|
||
offset; when hashed, only that the hash differs.
|
||
- A **memory region** is just a `bytes` value captured at a declared `(address, length)`;
|
||
the region's name is the key in `side`. Structured regions should be captured as
|
||
`struct` of typed values instead so diffs point at the field, not a byte offset.
|
||
|
||
## 4. Emitter rules (C++ without a JSON library)
|
||
|
||
The emitter is a set of `fprintf`s. What it must guarantee:
|
||
|
||
1. **Key order is fixed**: `ts, hook, mode, call_id, thread, depth, args, ret, side, ours,
|
||
diverged, diff, err, note`. (The harness does not care, but fixed order makes logs
|
||
`diff`-able by eye and grep-able.)
|
||
2. **Strings** (`hook`, `str`/`wstr` `v`, `n`, `err`, `note`, struct field names, side
|
||
names): write `"`, then for each unit:
|
||
- `"` → `\"`, `\` → `\\`
|
||
- `\n` → `\n`, `\r` → `\r`, `\t` → `\t` (literal backslash-letter)
|
||
- any other unit `< 0x20`, and for `str` any byte `>= 0x7f` → `\u00XX` (lowercase hex of
|
||
the byte), for `wstr` any unit `>= 0x7f` → `\uXXXX`
|
||
- everything else verbatim. Close with `"`.
|
||
This is exactly what Python's `json.dumps(ensure_ascii=True)` produces for a
|
||
latin-1-decoded byte string (except Python also short-forms `\b` and `\f`; the harness
|
||
accepts both). No raw bytes ≥ 0x80 ever appear in the log, so the file is ASCII-only.
|
||
3. **Numbers**: integers with `%lld`/`%llu`; floats with `%.9g` / `%.17g`; non-finite floats
|
||
as the strings `"nan"`, `"inf"`, `"-inf"`. Never emit `nan`/`inf` bare (invalid JSON).
|
||
4. **Booleans** `true`/`false`; **void** `null`.
|
||
5. **No trailing commas.** Build arrays/objects with a "first element" flag.
|
||
6. **One `fprintf` chain per record, ending in `"}\n"` and a `fflush`** at least on
|
||
`compare` divergences and on shim teardown, so a crash mid-run leaves a usable log
|
||
(the harness tolerates a truncated last line: it is reported as one invalid record).
|
||
7. **Thread safety**: take a lock around the whole record; `call_id` is
|
||
`InterlockedIncrement`-ed *before* the original is called, so nested hooks get
|
||
increasing ids and `depth` says who is inside whom.
|
||
8. `sha256`: any tiny public-domain SHA-256; hex lowercase.
|
||
|
||
## 5. `meta` record (first line)
|
||
|
||
```json
|
||
{"meta":{"format":1,"build":"sots-engine 0.0.3 g1a2b3c4","exe_sha256":"…",
|
||
"started":"2026-09-07T18:00:00Z","inline_max":256,
|
||
"hooks":{"CfgVar_RegisterKey":{"ftol":0,"ptr":"ignore"},
|
||
"Mars::ParseBlock":{"ftol":1e-6,"ftol_kind":"rel","unordered":["ret.v.items"]}}}}
|
||
```
|
||
|
||
- `format`: this spec's version (1). The harness refuses other versions.
|
||
- `hooks`: per-hook **policy**, the same keys `tracecmp.py --tolerance` accepts. CLI flags
|
||
override the log's policy; the log's policy overrides harness defaults.
|
||
- Everything else is informational and copied into the report.
|
||
|
||
## 6. Divergence rules
|
||
|
||
A call **diverges** when any of: `err` is set; `ret` differs; any `side[name].after`
|
||
differs from `ours.side[name].after`; a `side` name exists on one side only (`why:
|
||
"missing"`). Comparison is by the typed-value table above, recursively. Paths in diff
|
||
entries: `ret`, `side.<name>.after`, then `.v[<index>]` for list/set elements,
|
||
`.v.<field>` for struct fields, `.<key>`/`[<i>]` inside `json` values.
|
||
|
||
```json
|
||
{"path":"side.cfg_table.after.v.ForceSingleCore","why":"exact","orig":{"t":"i32","v":1},"ours":{"t":"i32","v":0}}
|
||
```
|
||
|
||
`why` ∈ `exact | ftol | type | len | missing | extra | hash | err`.
|
||
|
||
**Float tolerance policy** (per hook; default `ftol = 0`, i.e. exact by value with
|
||
`nan == nan`):
|
||
- `ftol`: number ≥ 0.
|
||
- `ftol_kind`: `"abs"` (default) — `|a-b| <= ftol`; `"rel"` — `|a-b| <= ftol * max(|a|,|b|)`;
|
||
`"ulp"` — within `ftol` units-in-last-place of the value's own width (f32/f64).
|
||
- `nan`, `inf`, `-inf`: equal only to themselves, whatever the tolerance.
|
||
- `f32` values are rounded to float32 on both sides before comparing: `%.9g` round-trips
|
||
the float32 exactly but not the double it was widened to. `f64` compares as-is.
|
||
- `f32` compared to `f64` is a type divergence; do not mix.
|
||
|
||
**Unordered fields**: `set` values are always unordered. A policy may additionally list
|
||
`unordered: [<path>, …]` to treat specific `list` paths as sets (path syntax as above,
|
||
without the `.v[i]` tail). Multiset semantics: element multiplicity matters.
|
||
|
||
**Pointers**: `ptr` is ignored unless the policy says `"ptr":"exact"`. Two `ptr` values
|
||
where exactly one is `"0x0"` is always a divergence (`null` vs non-null is semantic).
|
||
|
||
## 7. Canonical JSON (for `json` values and the oracle bridge)
|
||
|
||
The form a host-side C++ test dump must reproduce byte-for-byte so a plain `diff` (or
|
||
`tracecmp.py --replay`) works. It is Python's
|
||
`json.dumps(obj, sort_keys=True, ensure_ascii=True, separators=(",", ":"))` after
|
||
normalization by `oracle_parsers.canonical()`:
|
||
|
||
- objects: keys sorted by code point, no whitespace anywhere (`{"a":1,"b":[1,2]}`);
|
||
- strings: byte-preserving — the parsers decode cp1252, the canonicalizer re-encodes to
|
||
cp1252 and maps each byte to U+00XX, so the escaping in section 4 applies verbatim
|
||
(a C++ dump escapes raw bytes, no charset table needed);
|
||
- integers: decimal, no leading `+`/zeros;
|
||
- floats: rounded to **float32**, printed with `%.9g`, then `.0` appended when the text has
|
||
no `.`, `e` or `n` (so `1.0` stays distinguishable from the int `1`, and `-0` → `-0.0`);
|
||
non-finite → the strings `"nan"`, `"inf"`, `"-inf"`;
|
||
- booleans `true`/`false`; `null` for None; tuples become arrays; dataclasses become
|
||
objects of their fields;
|
||
- the file ends with a single `\n`.
|
||
|
||
## 8. Replay (offline) input
|
||
|
||
`tracecmp.py --replay GOLDEN.jsonl IMPL.jsonl`: `GOLDEN` is a normal log (any mode; usually
|
||
`trace`). `IMPL` is JSONL whose records need only `call_id`, `ret`, `side` (`side` may be
|
||
`{name: tv}` or `{name: {"after": tv}}`; `hook` optional but checked when present). Each
|
||
golden call_id must appear in `IMPL` (`why: "missing"` otherwise); extra call_ids in
|
||
`IMPL` are warnings. The diff rules are those of section 6, with `IMPL` playing `ours`.
|