Recover QVM authentication labels and announcement recipients
Browse files- docs/FINDINGS.md +6 -0
- docs/PROVISIONAL_TODO.md +1 -1
- docs/RESEARCH_LEDGER.md +4 -0
- docs/REVERSE_ENGINEERING_TODO.md +6 -1
- docs/research/AUTH_ANNOUNCEMENTS.md +62 -0
- docs/research/README.md +4 -1
- docs/research/auth-announcements.json +0 -0
- scripts/analyze_auth_announcements.py +115 -0
- scripts/analyze_binaries.py +1 -1
- scripts/analyze_deferred_auth.py +11 -0
- scripts/test_auth_announcements.py +83 -0
docs/FINDINGS.md
CHANGED
|
@@ -216,3 +216,9 @@ Exact native fork/source revision, runtime resource lookup, legacy launch enviro
|
|
| 216 |
**F-042 — Observed Linux ELF64 address-type chain:** client field 0x24ee4 is netchannel address type, with bot zero, loopback two and an IPv4-format path at four. Bot allocation writes zero and drop cleanup uses the corresponding bot-free path. This resolves F-041’s unnamed count field: the pause predicate excludes bots. Complete lifecycle and runtime behavior remain open. [EVT-001](research/EVENT_QUEUES.md).
|
| 217 |
|
| 218 |
**F-043 — Observed native ring/clock paths, synthetic journal validation:** platform/common queues retain 256/1024 records of 32 bytes; ordinary full insertion frees the overwritten nonnull payload and advances tail. Common overflow warnings are throttled until a nonfull insertion. Com_Milliseconds preserves nonzero real events in the common queue before returning a zero-type event timestamp, including journal-derived time. A bounded Linux ELF64 journal inspector preserves pointer/padding metadata without dereferencing it. No original capture or runtime conformance is claimed. [EVT-002/003](research/EVENT_QUEUES.md).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
**F-042 — Observed Linux ELF64 address-type chain:** client field 0x24ee4 is netchannel address type, with bot zero, loopback two and an IPv4-format path at four. Bot allocation writes zero and drop cleanup uses the corresponding bot-free path. This resolves F-041’s unnamed count field: the pause predicate excludes bots. Complete lifecycle and runtime behavior remain open. [EVT-001](research/EVENT_QUEUES.md).
|
| 217 |
|
| 218 |
**F-043 — Observed native ring/clock paths, synthetic journal validation:** platform/common queues retain 256/1024 records of 32 bytes; ordinary full insertion frees the overwritten nonnull payload and advances tail. Common overflow warnings are throttled until a nonfull insertion. Com_Milliseconds preserves nonzero real events in the common queue before returning a zero-type event timestamp, including journal-derived time. A bounded Linux ELF64 journal inspector preserves pointer/padding metadata without dereferencing it. No original capture or runtime conformance is claimed. [EVT-002/003](research/EVENT_QUEUES.md).
|
| 219 |
+
|
| 220 |
+
## Authentication announcement update — 2026-10-11
|
| 221 |
+
|
| 222 |
+
**F-044 — Observed table and branch evidence:** qagame helper 165352 chooses signed status values -1..2 or role values -1..8 according to the byte-count helper result for record+3624. Negative one produces an empty suffix; out-of-range values produce ERROR. The role table contains Disabled through Frozen Sand Developer labels. These are presentation values, not a recovered permission policy. [ANN-001](research/AUTH_ANNOUNCEMENTS.md).
|
| 223 |
+
|
| 224 |
+
**F-045 — Observed recipient paths, bounded synthetic fixtures:** mode one targets record+140 with print; mode two selects diagnostics for a zero third argument or targeted detailed chat via a client-pointer/6336 calculation. Other modes consult global 35002972 for broadcast print/chat or no output. Linux trap 17 maps recipient -1 to NULL broadcast selection; native command forwarding rejects length greater than 1022. Only two direct CONST-3200 references are found, both begin read/set; bulk reset and alias behavior remain unknown. [ANN-002/003](research/AUTH_ANNOUNCEMENTS.md).
|
docs/PROVISIONAL_TODO.md
CHANGED
|
@@ -33,7 +33,7 @@ For implementation items, the minimum done condition is a documented contract, a
|
|
| 33 |
|
| 34 |
### First research batch
|
| 35 |
|
| 36 |
-
Progress: [RE-232–234 / TIME-001–003](research/FRAME_TIMING.md) now cover Linux ELF64 elapsed modification, event sources and pause predicates. The [RE-237–239 follow-up](research/EVENT_QUEUES.md) resolves the pause-field address/bot chain and adds queue/journal evidence. PROV-001 remains open for complete scheduling and platform coverage.
|
| 37 |
|
| 38 |
Start with the open questions raised by the current binary evidence. These tasks depend on the linked reports, not on a working rewrite.
|
| 39 |
|
|
|
|
| 33 |
|
| 34 |
### First research batch
|
| 35 |
|
| 36 |
+
Progress: [RE-232–234 / TIME-001–003](research/FRAME_TIMING.md) now cover Linux ELF64 elapsed modification, event sources and pause predicates. The [RE-237–239 follow-up](research/EVENT_QUEUES.md) resolves the pause-field address/bot chain and adds queue/journal evidence. The [RE-241–243 announcement pass](research/AUTH_ANNOUNCEMENTS.md) recovers label tables and recipient paths; PROV-002 remains open for full output/reset coverage. PROV-001 remains open for complete scheduling and platform coverage.
|
| 37 |
|
| 38 |
Start with the open questions raised by the current binary evidence. These tasks depend on the linked reports, not on a working rewrite.
|
| 39 |
|
docs/RESEARCH_LEDGER.md
CHANGED
|
@@ -101,3 +101,7 @@ Completed bounded static milestones RE-232–234 with `analyze_frame_timing.py`:
|
|
| 101 |
## 2026-10-11 — Address-type semantics and event queue ownership
|
| 102 |
|
| 103 |
Completed static milestones RE-237–239 with `analyze_event_queues.py`: sixteen Linux ELF64 graphs, queue symbol sizes, file-backed address labels, caller chains and 108 direct field references per image. EVT-001–003/F-042/043 resolve the pause field to address type, document bot exclusion and ordinary queue overflow/warning/reset paths, and establish Com_Milliseconds event preservation. Added a bounded raw-journal inspector with eight tests covering provenance/layout evidence, mixed synthetic records, truncation, negative/oversized payloads, limits and ambiguous symbols. RE-235/236 remain open; RE-240 tracks full ownership/wrap/runtime/platform recovery. No original executable or journal capture was run/produced.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
## 2026-10-11 — Address-type semantics and event queue ownership
|
| 102 |
|
| 103 |
Completed static milestones RE-237–239 with `analyze_event_queues.py`: sixteen Linux ELF64 graphs, queue symbol sizes, file-backed address labels, caller chains and 108 direct field references per image. EVT-001–003/F-042/043 resolve the pause field to address type, document bot exclusion and ordinary queue overflow/warning/reset paths, and establish Com_Milliseconds event preservation. Added a bounded raw-journal inspector with eight tests covering provenance/layout evidence, mixed synthetic records, truncation, negative/oversized payloads, limits and ambiguous symbols. RE-235/236 remain open; RE-240 tracks full ownership/wrap/runtime/platform recovery. No original executable or journal capture was run/produced.
|
| 104 |
+
|
| 105 |
+
## 2026-10-11 — Authentication role/status and output routing
|
| 106 |
+
|
| 107 |
+
Completed bounded milestones RE-241–243 with `analyze_auth_announcements.py`: initialized-data jump tables, 18 label paths, nine recipient paths, diagnostic wrapper chain, Linux ELF64 trap-17 NULL broadcast selection/command-size boundary and all direct CONST-3200 references. ANN-001–003/F-044/045 distinguish text labels from permissions and literal scans from complete reset coverage. Extended the bounded QVM evaluator with restricted LSH and signed DIVI; seven tests check tables, recipients, native boundaries and invalid arithmetic/table inputs. Formatting/copy callees remain unexecuted with explicit placeholder return values. RE-230 remains open and RE-244 records remaining caller/layout/global/reset/runtime requirements.
|
docs/REVERSE_ENGINEERING_TODO.md
CHANGED
|
@@ -269,7 +269,7 @@ Purpose: turn the supplied installation into an evidence-backed specification of
|
|
| 269 |
- [x] RE-227 Recover Linux ELF64 server tick units, timescale calculation, game-time argument to VM command 8 and the native time-wrap guard. **Evidence:** CTL-001, both SV_Frame graphs/windows and Sys_Milliseconds bodies. Full scheduling/reset/runtime behavior remains RE-226.
|
| 270 |
- [x] RE-228 Recover ordinary Linux mode-1 error shutdown/frame-abort path and build-script, repeated-error and recursion escalation branches. **Evidence:** CTL-002; both Com_Error, Com_Frame and Sys_Error graphs. Full helper cleanup and other platforms remain RE-225.
|
| 271 |
- [x] RE-229 Identify begin callback field-3200 one-shot gate and helper 165352 as an authentication status announcement formatter/service caller. **Evidence:** CTL-003, helper body/string operands and five synthetic gate cases; recipient/state-update semantics remain open.
|
| 272 |
-
- [ ] RE-230 Recover authentication announcement mode/recipient/role formatting branches and every field-3200 reset or alias write; compare UI output with deterministic runtime fixtures.
|
| 273 |
- [ ] RE-231 Trace native event-loop elapsed-time production, timescale/pause/catch-up/map-restart paths and connect/begin/frame callback ordering on every platform.
|
| 274 |
|
| 275 |
- [x] RE-232 Recover Linux ELF64 frame modification constants, fixedtime/timescale/camera branches, upper bounds and hitch threshold with decoded-instruction traces ([TIME-001](research/FRAME_TIMING.md)).
|
|
@@ -282,3 +282,8 @@ Purpose: turn the supplied installation into an evidence-backed specification of
|
|
| 282 |
- [x] RE-238 Recover both Linux ELF64 event ring sizes, producer layouts, overflow/free paths and warning/reset behavior ([EVT-002](research/EVENT_QUEUES.md)).
|
| 283 |
- [x] RE-239 Recover Linux ELF64 Com_Milliseconds event-preservation side effects and add a bounded inspector for the observed journal layout with malformed-input fixtures ([EVT-003](research/EVENT_QUEUES.md)).
|
| 284 |
- [ ] RE-240 Recover every event producer/consumer/reset ownership path, counter wrap and concurrency semantics; validate burst/overflow traces and journal layouts across all supplied platforms.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
- [x] RE-227 Recover Linux ELF64 server tick units, timescale calculation, game-time argument to VM command 8 and the native time-wrap guard. **Evidence:** CTL-001, both SV_Frame graphs/windows and Sys_Milliseconds bodies. Full scheduling/reset/runtime behavior remains RE-226.
|
| 270 |
- [x] RE-228 Recover ordinary Linux mode-1 error shutdown/frame-abort path and build-script, repeated-error and recursion escalation branches. **Evidence:** CTL-002; both Com_Error, Com_Frame and Sys_Error graphs. Full helper cleanup and other platforms remain RE-225.
|
| 271 |
- [x] RE-229 Identify begin callback field-3200 one-shot gate and helper 165352 as an authentication status announcement formatter/service caller. **Evidence:** CTL-003, helper body/string operands and five synthetic gate cases; recipient/state-update semantics remain open.
|
| 272 |
+
- [ ] RE-230 Recover authentication announcement mode/recipient/role formatting branches and every field-3200 reset or alias write; compare UI output with deterministic runtime fixtures. **Progress:** RE-241–243 recover static tables, recipient paths and direct flag references; full output/caller/reset semantics remain open ([ANN-001–003](research/AUTH_ANNOUNCEMENTS.md)).
|
| 273 |
- [ ] RE-231 Trace native event-loop elapsed-time production, timescale/pause/catch-up/map-restart paths and connect/begin/frame callback ordering on every platform.
|
| 274 |
|
| 275 |
- [x] RE-232 Recover Linux ELF64 frame modification constants, fixedtime/timescale/camera branches, upper bounds and hitch threshold with decoded-instruction traces ([TIME-001](research/FRAME_TIMING.md)).
|
|
|
|
| 282 |
- [x] RE-238 Recover both Linux ELF64 event ring sizes, producer layouts, overflow/free paths and warning/reset behavior ([EVT-002](research/EVENT_QUEUES.md)).
|
| 283 |
- [x] RE-239 Recover Linux ELF64 Com_Milliseconds event-preservation side effects and add a bounded inspector for the observed journal layout with malformed-input fixtures ([EVT-003](research/EVENT_QUEUES.md)).
|
| 284 |
- [ ] RE-240 Recover every event producer/consumer/reset ownership path, counter wrap and concurrency semantics; validate burst/overflow traces and journal layouts across all supplied platforms.
|
| 285 |
+
|
| 286 |
+
- [x] RE-241 Recover qagame authentication status/role jump tables, signed guards, exact label strings and empty/default cases with bounded instruction fixtures ([ANN-001](research/AUTH_ANNOUNCEMENTS.md)).
|
| 287 |
+
- [x] RE-242 Recover qagame announcement mode/third-argument recipient selection, diagnostic-print chain and Linux ELF64 trap-17 broadcast/length boundary ([ANN-002](research/AUTH_ANNOUNCEMENTS.md)).
|
| 288 |
+
- [x] RE-243 Index every direct CONST-3200 qagame reference and distinguish begin read/set from unproven reset/alias coverage ([ANN-003](research/AUTH_ANNOUNCEMENTS.md)).
|
| 289 |
+
- [ ] RE-244 Recover all announcement callers, third-argument object layout, global 35002972 registration/updates, flag bulk-reset/alias paths, reliable-command admission/escaping limits and rendered-output runtime fixtures.
|
docs/research/AUTH_ANNOUNCEMENTS.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# QVM authentication announcement tables and recipients
|
| 2 |
+
|
| 3 |
+
Static pass, 2026-10-11. Reproduce with `python3 scripts/analyze_auth_announcements.py`; check with `python3 scripts/test_auth_announcements.py`. [Generated evidence](auth-announcements.json) preserves the source-hashed qagame helper 165352, both initialized-data jump tables, 18 synthetic label paths, nine synthetic recipient paths, direct flag references and Linux ELF64 send-command boundary evidence. No original process or complete VM is launched.
|
| 4 |
+
|
| 5 |
+
## ANN-001: status versus role text
|
| 6 |
+
|
| 7 |
+
Helper 165352 uses its first argument as a client record pointer. Its second argument is a mode; mode exactly one supplies prefix `^7[pm] `, while other modes clear that prefix. It calls helper 36038 on `record + 3624` and chooses the short status path when the result is signed less than two; otherwise it chooses the account/role path. The retained helper reads successive four-byte words, tests individual bytes for NUL and returns a byte count. Its read-ahead/memory boundary behavior still needs independent validation.
|
| 8 |
+
|
| 9 |
+
Both label paths read signed field `record + 3688`, but interpret it with different tables. Table addresses are VM memory addresses; entries contain **instruction indices**, not byte offsets.
|
| 10 |
+
|
| 11 |
+
| Field value | Short status label | Account/role label |
|
| 12 |
+
| --- | --- | --- |
|
| 13 |
+
| -1 | Empty | Empty |
|
| 14 |
+
| 0 | no account | Disabled |
|
| 15 |
+
| 1 | auth server disabled | Registered |
|
| 16 |
+
| 2 | this server is banned | Friend |
|
| 17 |
+
| 3 | ERROR | Member |
|
| 18 |
+
| 4 | ERROR | Referee |
|
| 19 |
+
| 5 | ERROR | Admin |
|
| 20 |
+
| 6 | ERROR | Head Admin |
|
| 21 |
+
| 7 | ERROR | Accounts Admin |
|
| 22 |
+
| 8 | ERROR | Frozen Sand Developer |
|
| 23 |
+
| Outside the applicable range | ERROR | ERROR |
|
| 24 |
+
|
| 25 |
+
The report preserves exact color codes and leading separators. Status table base 29152 accepts -1 through 2, reading entries from 29148 through 29160. Role table base 29168 accepts -1 through 8, reading entries from 29164 through 29200. Explicit signed lower/upper guards route out-of-range values to default ERROR labels. Negative one selects a direct empty-string byte write; the other selected labels are copied through helper 69338 with capacity 128.
|
| 26 |
+
|
| 27 |
+
The short summary format is `%s^5[auth] ^7%s^7%s^7 `, with prefix, `record + 504` and status suffix. The account summary is `%s^5[auth] ^7%s^7%s^7 - %s account: ^7%s^7 `, adding strings at offsets 3656 and 3592. Field names beyond their formatting roles remain provisional. Role labels identify presentation values; they do not prove permissions, server trust or authorization checks.
|
| 28 |
+
|
| 29 |
+
Synthetic fixtures start after the short/account branch selection and evaluate every table value plus both out-of-range boundaries against decoded instructions and table memory. They do not execute the length/copy/format callees or verify final rendered text.
|
| 30 |
+
|
| 31 |
+
## ANN-002: mode and recipient paths
|
| 32 |
+
|
| 33 |
+
At instructions 165642–165815, the helper selects these paths:
|
| 34 |
+
|
| 35 |
+
| Mode | Third argument / global | Observed output |
|
| 36 |
+
| --- | --- | --- |
|
| 37 |
+
| 1 | Third argument ignored in this tail | Format `print "%s"`; trap 17 recipient is signed field `record + 140`. |
|
| 38 |
+
| 2 | Third argument zero | Diagnostic helper 5123 receives a detailed `auth: id: ...` format and fields; no trap 17 in this tail. |
|
| 39 |
+
| 2 | Third argument nonzero | Format detailed `chat "^5[auth] ..."`; recipient is `(load(third_argument + 520) - load(global 34992496)) / 6336`. |
|
| 40 |
+
| Any mode other than 1 or 2 | Global 35002972 equals 1 | Format `print "%s"`; recipient -1. |
|
| 41 |
+
| Any mode other than 1 or 2 | Global 35002972 equals 2 | Format `chat "%s"`; recipient -1. |
|
| 42 |
+
| Any mode other than 1 or 2 | Other global value | No output call in this tail. |
|
| 43 |
+
|
| 44 |
+
The targeted mode therefore uses the third argument as a pointer to an object with a client-record pointer at offset 520, rather than simply as a boolean or recipient slot. Its full object type and caller validity remain to be recovered. The divisor matches the earlier qagame client stride. Fixtures choose a record number of two and a target slot of three, proving that these branches select different recipients. Modes zero, three and negative one all follow the global-controlled fallback in the tested inputs; there is no mode-zero-only guard.
|
| 45 |
+
|
| 46 |
+
Diagnostic helper 5123 formats into a local buffer, forwards through helper 2294, and reaches game print trap zero (encoded CALL target -1). This distinguishes engine diagnostic output from server-command chat. The detailed formats take record offset 140, strings at 504/3624/3656, field 3688 and the selected suffix.
|
| 47 |
+
|
| 48 |
+
Both Linux ELF64 trap-17 cases translate the command pointer, recognize recipient -1 and pass a NULL client pointer to `SV_SendServerCommand`. Nonnegative indices below sv_maxclients become pointers using native stride 0x2df40; other indices follow the rejected-index continuation. A NULL client path fans out through `SV_AddServerCommand` over native slots, subject to that helper’s behavior. Thus -1 is the broadcast-selection convention at this boundary, not a guarantee that every connected client displays the text.
|
| 49 |
+
|
| 50 |
+
`SV_SendServerCommand` formats using a 16384-byte buffer and refuses forwarding when the resulting string length is unsigned greater than 0x3fe (1022). Exact reliable-queue admission, truncation/escaping behavior and client command parsing remain follow-ups. The 2048-byte QVM summary buffer alone does not guarantee delivery of every formatted message.
|
| 51 |
+
|
| 52 |
+
Calls in the synthetic evaluator are recorded without entering callees. Their zero return values are placeholders: a recorded command-pointer argument of zero is **not** evidence that the actual game sends a null string. These fixtures establish selected branches and recipient arithmetic only. Signed DIVI truncates toward zero within the evaluator’s supported domain; divide-by-zero and INT_MIN/-1 are rejected by the tool rather than assigned invented game behavior.
|
| 53 |
+
|
| 54 |
+
## ANN-003: one-shot flag reference coverage
|
| 55 |
+
|
| 56 |
+
A complete literal CONST-3200 scan over qagame finds only instructions 175006 and 175013, both in begin callback candidate 174399. The first is the flag read; the second leads to writing one before invoking announcement helper 165352. No literal-offset reset is found.
|
| 57 |
+
|
| 58 |
+
This does **not** mean the flag never resets. Client-record initialization, bulk clears/copies, pointer aliases, computed offsets or a base address incorporating 3200 can access it without that CONST operand. RE-230 remains open until those paths and original-runtime output are verified. The existing one-shot begin gate evidence is retained in [CTL-003](ENGINE_CONTROL.md).
|
| 59 |
+
|
| 60 |
+
## Completed slices and remaining work
|
| 61 |
+
|
| 62 |
+
RE-241–243 cover signed status/role table recovery, bounded mode/recipient and native command-boundary evidence, and direct flag-reference coverage. RE-230 remains the complete announcement contract task. RE-244 tracks caller argument/object semantics, global registration/update, full flag reset/alias coverage, reliable-command limits and deterministic rendered-output fixtures. Preserve literal labels and raw field evidence before assigning domain enums to the Rust rewrite.
|
docs/research/README.md
CHANGED
|
@@ -30,10 +30,11 @@ python3 scripts/test_deferred_auth.py
|
|
| 30 |
python3 scripts/test_engine_control.py
|
| 31 |
python3 scripts/test_frame_timing.py
|
| 32 |
python3 scripts/test_event_queues.py
|
|
|
|
| 33 |
python3 scripts/storage_policy.py check --require-setup
|
| 34 |
```
|
| 35 |
|
| 36 |
-
The pipeline runs `fetch_analysis_references.py`, `analyze_qvm.py`, `analyze_native.py`, `analyze_vm_abi.py`, `analyze_native_dispatch.py`, `analyze_vm_extensions.py`, `analyze_native_metadata.py`, `analyze_native_coverage.py`, `analyze_auth_drop.py`, `analyze_deferred_auth.py`, `analyze_engine_control.py`, `analyze_frame_timing.py`, and `
|
| 37 |
|
| 38 |
Generated JSON/JSONL and compressed evidence are regenerated in place; keep prose notes in Markdown. Rerunning the four analysis stages reproduced every generated report and gzip artifact byte-for-byte on the same toolchain. gzip timestamps and stored filenames are fixed. Different objdump versions can produce different disassembly text, so tool version and uncompressed evidence hashes are recorded. Reports are UTF-8 normal Git text, with individual files below the storage policy's 2 MiB limit.
|
| 39 |
|
|
@@ -61,3 +62,5 @@ The extension collector adds five focused tests for native tail calls, command-r
|
|
| 61 |
[Frame elapsed, event sources and pause gates](FRAME_TIMING.md) adds RE-232–234, ten Linux ELF64 graphs and 64 bounded native instruction traces. Reproduce with `python3 scripts/analyze_frame_timing.py`; check with `python3 scripts/test_frame_timing.py`. Original-runtime and all-platform scheduling remain open.
|
| 62 |
|
| 63 |
[Native address types, event queues and journals](EVENT_QUEUES.md) adds RE-237–239: the pause-field address/bot chain, two queue policies, clock-query event preservation and a bounded Linux ELF64 journal inspector. Reproduce with `python3 scripts/analyze_event_queues.py`; check with `python3 scripts/test_event_queues.py`.
|
|
|
|
|
|
|
|
|
| 30 |
python3 scripts/test_engine_control.py
|
| 31 |
python3 scripts/test_frame_timing.py
|
| 32 |
python3 scripts/test_event_queues.py
|
| 33 |
+
python3 scripts/test_auth_announcements.py
|
| 34 |
python3 scripts/storage_policy.py check --require-setup
|
| 35 |
```
|
| 36 |
|
| 37 |
+
The pipeline runs `fetch_analysis_references.py`, `analyze_qvm.py`, `analyze_native.py`, `analyze_vm_abi.py`, `analyze_native_dispatch.py`, `analyze_vm_extensions.py`, `analyze_native_metadata.py`, `analyze_native_coverage.py`, `analyze_auth_drop.py`, `analyze_deferred_auth.py`, `analyze_engine_control.py`, `analyze_frame_timing.py`, `analyze_event_queues.py`, and `analyze_auth_announcements.py` in that order. Python 3.9+ and **LLVM objdump** with x86 ELF/COFF/Mach-O support are required. This pass used Apple LLVM 17.0.0 on macOS arm64; it disassembled x86 images without running them. Upstream references are downloaded only if absent, checked against the committed SHA-256 manifest, and cached under `.cache/research/upstream/`. A clone also needs `git lfs pull` to obtain the derived artifacts/extracted members.
|
| 38 |
|
| 39 |
Generated JSON/JSONL and compressed evidence are regenerated in place; keep prose notes in Markdown. Rerunning the four analysis stages reproduced every generated report and gzip artifact byte-for-byte on the same toolchain. gzip timestamps and stored filenames are fixed. Different objdump versions can produce different disassembly text, so tool version and uncompressed evidence hashes are recorded. Reports are UTF-8 normal Git text, with individual files below the storage policy's 2 MiB limit.
|
| 40 |
|
|
|
|
| 62 |
[Frame elapsed, event sources and pause gates](FRAME_TIMING.md) adds RE-232–234, ten Linux ELF64 graphs and 64 bounded native instruction traces. Reproduce with `python3 scripts/analyze_frame_timing.py`; check with `python3 scripts/test_frame_timing.py`. Original-runtime and all-platform scheduling remain open.
|
| 63 |
|
| 64 |
[Native address types, event queues and journals](EVENT_QUEUES.md) adds RE-237–239: the pause-field address/bot chain, two queue policies, clock-query event preservation and a bounded Linux ELF64 journal inspector. Reproduce with `python3 scripts/analyze_event_queues.py`; check with `python3 scripts/test_event_queues.py`.
|
| 65 |
+
|
| 66 |
+
[Authentication announcement tables and recipients](AUTH_ANNOUNCEMENTS.md) adds RE-241–243: signed status/role labels, bounded mode/recipient fixtures, Linux command-boundary evidence and direct flag references. Reproduce with `python3 scripts/analyze_auth_announcements.py`; check with `python3 scripts/test_auth_announcements.py`.
|
docs/research/auth-announcements.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
scripts/analyze_auth_announcements.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Recover qagame authentication announcement tables and bounded recipient paths."""
|
| 3 |
+
import json
|
| 4 |
+
import gzip
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import re
|
| 7 |
+
import struct
|
| 8 |
+
|
| 9 |
+
from analyze_auth_drop import ascii_literal
|
| 10 |
+
from analyze_deferred_auth import partition_function, trace_slice
|
| 11 |
+
from analyze_engine_control import control_flow
|
| 12 |
+
from analyze_native_dispatch import recover
|
| 13 |
+
from analyze_qvm import decode, write_json
|
| 14 |
+
from analyze_vm_extensions import native_functions, verified
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def recover_table(data, header, instructions, base, lower, upper, entry, end):
|
| 18 |
+
start = base + lower*4
|
| 19 |
+
size = (upper-lower+1)*4
|
| 20 |
+
if lower > upper or start < 0 or start+size > header['data_length']:
|
| 21 |
+
raise ValueError('jump table outside initialized data')
|
| 22 |
+
if instructions[entry]['opcode'] != 'ENTER' or not entry < end <= len(instructions):
|
| 23 |
+
raise ValueError('invalid function interval')
|
| 24 |
+
rows = []
|
| 25 |
+
for value in range(lower, upper+1):
|
| 26 |
+
address = base+value*4
|
| 27 |
+
offset = header['data_offset']+address
|
| 28 |
+
if offset+4 > len(data):
|
| 29 |
+
raise ValueError('truncated jump table')
|
| 30 |
+
target = struct.unpack_from('<I', data, offset)[0]
|
| 31 |
+
if not entry < target < end:
|
| 32 |
+
raise ValueError('jump target outside announcement body')
|
| 33 |
+
rows.append({'value': value, 'vm_address': address, 'file_offset': offset,
|
| 34 |
+
'target_instruction': target, 'entry_window': instructions[target:target+12]})
|
| 35 |
+
return {'base': base, 'lower': lower, 'upper': upper, 'rows': rows}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
report = json.loads(Path('docs/research/qvm/qagame.json').read_text())
|
| 40 |
+
data = verified(Path(report['source']['member']['output']).read_bytes(), report['sha256'])
|
| 41 |
+
header, ins = decode(data)
|
| 42 |
+
function = partition_function(ins, 165352, data, header)
|
| 43 |
+
end = function['linear_end_exclusive']
|
| 44 |
+
tables = {'status': recover_table(data, header, ins, 29152, -1, 2, 165352, end),
|
| 45 |
+
'role': recover_table(data, header, ins, 29168, -1, 8, 165352, end)}
|
| 46 |
+
# Verify these constants occur in the supplied computed-jump expressions.
|
| 47 |
+
for at, expected in [(165403, 29152), (165493, 29168)]:
|
| 48 |
+
if ins[at]['opcode'] != 'CONST' or ins[at]['operand'] != expected:
|
| 49 |
+
raise ValueError('announcement switch identity changed')
|
| 50 |
+
memory = {row['vm_address']: row['target_instruction'] for table in tables.values() for row in table['rows']}
|
| 51 |
+
formatting = {}
|
| 52 |
+
for name, start, stop, values in [('status', 165384, 165454, range(-2, 4)),
|
| 53 |
+
('role', 165474, 165610, range(-2, 10))]:
|
| 54 |
+
traces = []
|
| 55 |
+
for value in values:
|
| 56 |
+
model = trace_slice(ins, start, stop, {**memory, 0x10000+3688: value}, {2272: 0x10000})
|
| 57 |
+
copies = []
|
| 58 |
+
for call in model['calls']:
|
| 59 |
+
if call['target'] != 69338:
|
| 60 |
+
raise ValueError('unexpected label formatter')
|
| 61 |
+
address = call['args'][12]
|
| 62 |
+
text = ascii_literal(data, header['data_offset']+address,
|
| 63 |
+
header['data_offset']+header['data_length']+header['literal_length'])
|
| 64 |
+
if text is None:
|
| 65 |
+
raise ValueError('missing status/role label')
|
| 66 |
+
copies.append({'vm_address': address, 'text': text, 'capacity': call['args'][16]})
|
| 67 |
+
traces.append({'input_value': value, 'selected_labels': copies,
|
| 68 |
+
'empty_string_writes': model['byte_writes'], 'trace': model['trace']})
|
| 69 |
+
formatting[name] = traces
|
| 70 |
+
recipient_traces = []
|
| 71 |
+
record, target_entity, client_base = 0x10000, 0x30000, 0x50000
|
| 72 |
+
for mode, target, broadcast in [(1, 0, 0), (1, target_entity, 2), (2, 0, 0),
|
| 73 |
+
(2, target_entity, 0), (0, 0, 1), (0, 0, 2),
|
| 74 |
+
(0, 0, 0), (3, 0, 1), (-1, 0, 2)]:
|
| 75 |
+
state = {record+140: 2, record+3688: 5, 35002972: broadcast,
|
| 76 |
+
target_entity+520: client_base+3*6336, 34992496: client_base}
|
| 77 |
+
model = trace_slice(ins, 165642, 165816, state, {2272: record, 2276: mode, 2280: target})
|
| 78 |
+
recipient_traces.append({'mode': mode, 'third_argument': target, 'broadcast_global': broadcast,
|
| 79 |
+
'record_number': 2, 'target_slot': 3, 'calls': model['calls'], 'trace': model['trace']})
|
| 80 |
+
references, entry = [], None
|
| 81 |
+
for i in ins:
|
| 82 |
+
if i['opcode'] == 'ENTER':
|
| 83 |
+
entry = i['index']
|
| 84 |
+
if i['opcode'] == 'CONST' and i['operand'] == 3200:
|
| 85 |
+
references.append({'instruction': i['index'], 'function_entry': entry,
|
| 86 |
+
'window': ins[max(entry, i['index']-5):i['index']+12]})
|
| 87 |
+
output = {'source': report['source'], 'sha256': report['sha256'], 'announcement_function': function,
|
| 88 |
+
'tables': tables, 'synthetic_label_traces': formatting,
|
| 89 |
+
'synthetic_recipient_traces': recipient_traces, 'constant_3200_references': references,
|
| 90 |
+
'support_functions': {str(e): partition_function(ins, e, data, header) for e in [36038, 5123, 2294]},
|
| 91 |
+
'limitations': 'Static qagame and synthetic memory only. Calls are recorded, not entered; '
|
| 92 |
+
'their zero return values are placeholders, so formatted command pointers are not recovered outputs. '
|
| 93 |
+
'Literal displacement scanning does not recover bulk clears or alias writes; no permissions/runtime claim.'}
|
| 94 |
+
output['native'] = {}
|
| 95 |
+
for label in ('linux--Quake3-UrT-Ded.x86_64', 'linux--Quake3-UrT.x86_64'):
|
| 96 |
+
native = json.loads(Path(f'docs/research/native/{label}.json').read_text())
|
| 97 |
+
raw = verified((Path('.cache/UrbanTerror43')/native['source']['path']).read_bytes(), native['source']['sha256'])
|
| 98 |
+
artifact = native['artifacts']['disassembly.txt']
|
| 99 |
+
text = verified(gzip.open(artifact['path'], 'rb').read(), artifact['uncompressed_sha256']).decode()
|
| 100 |
+
functions = native_functions(text)
|
| 101 |
+
dispatch = recover(raw, text, 'SV_GameSystemCalls')
|
| 102 |
+
case = next(c for c in dispatch['cases'] if 17 in c['numbers_sharing_target'])
|
| 103 |
+
branch = next(line for line in case['first_instructions'] if '\tje\t' in line)
|
| 104 |
+
target = int(re.search(r'\tje\t0x([0-9a-f]+)', branch)[1], 16)
|
| 105 |
+
graph = control_flow(functions['SV_GameSystemCalls'])
|
| 106 |
+
index = next(k for k, i in enumerate(graph['instructions']) if i['address'] == target)
|
| 107 |
+
output['native'][label] = {'source': native['source'], 'disassembly_sha256': artifact['uncompressed_sha256'],
|
| 108 |
+
'trap_17': case, 'minus_one_branch': graph['instructions'][index:index+6],
|
| 109 |
+
'graphs': {name: control_flow(functions[name]) for name in ['SV_SendServerCommand', 'SV_AddServerCommand']}}
|
| 110 |
+
write_json(Path('docs/research/auth-announcements.json'), output)
|
| 111 |
+
print('Recovered status/role tables, eighteen label cases, nine recipient paths and flag references')
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == '__main__':
|
| 115 |
+
main()
|
scripts/analyze_binaries.py
CHANGED
|
@@ -12,7 +12,7 @@ def main():
|
|
| 12 |
'analyze_vm_abi.py', 'analyze_native_dispatch.py', 'analyze_vm_extensions.py',
|
| 13 |
'analyze_native_metadata.py', 'analyze_native_coverage.py', 'analyze_auth_drop.py',
|
| 14 |
'analyze_deferred_auth.py', 'analyze_engine_control.py', 'analyze_frame_timing.py',
|
| 15 |
-
'analyze_event_queues.py'):
|
| 16 |
subprocess.run([sys.executable, str(Path('scripts')/script)], check=True)
|
| 17 |
|
| 18 |
|
|
|
|
| 12 |
'analyze_vm_abi.py', 'analyze_native_dispatch.py', 'analyze_vm_extensions.py',
|
| 13 |
'analyze_native_metadata.py', 'analyze_native_coverage.py', 'analyze_auth_drop.py',
|
| 14 |
'analyze_deferred_auth.py', 'analyze_engine_control.py', 'analyze_frame_timing.py',
|
| 15 |
+
'analyze_event_queues.py', 'analyze_auth_announcements.py'):
|
| 16 |
subprocess.run([sys.executable, str(Path('scripts')/script)], check=True)
|
| 17 |
|
| 18 |
|
scripts/analyze_deferred_auth.py
CHANGED
|
@@ -51,6 +51,17 @@ def trace_slice(instructions, start, stop, memory, locals_map, limit=256):
|
|
| 51 |
elif op in ('ADD', 'SUB', 'MULI'):
|
| 52 |
right, left = stack.pop(), stack.pop()
|
| 53 |
stack.append(signed32(left+right if op == 'ADD' else left-right if op == 'SUB' else left*right))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
elif op in ('EQ', 'NE', 'LTI', 'LEI', 'GTI', 'GEI'):
|
| 55 |
right, left = signed32(stack.pop()), signed32(stack.pop())
|
| 56 |
condition = {'EQ': left == right, 'NE': left != right, 'LTI': left < right,
|
|
|
|
| 51 |
elif op in ('ADD', 'SUB', 'MULI'):
|
| 52 |
right, left = stack.pop(), stack.pop()
|
| 53 |
stack.append(signed32(left+right if op == 'ADD' else left-right if op == 'SUB' else left*right))
|
| 54 |
+
elif op == 'LSH':
|
| 55 |
+
right, left = stack.pop(), stack.pop()
|
| 56 |
+
if not 0 <= right < 32:
|
| 57 |
+
raise ValueError('shift outside bounded evaluator domain')
|
| 58 |
+
stack.append(signed32(left << right))
|
| 59 |
+
elif op == 'DIVI':
|
| 60 |
+
right, left = signed32(stack.pop()), signed32(stack.pop())
|
| 61 |
+
if right == 0 or (left == -(2**31) and right == -1):
|
| 62 |
+
raise ValueError('division outside bounded evaluator domain')
|
| 63 |
+
quotient = abs(left) // abs(right)
|
| 64 |
+
stack.append(-quotient if (left < 0) != (right < 0) else quotient)
|
| 65 |
elif op in ('EQ', 'NE', 'LTI', 'LEI', 'GTI', 'GEI'):
|
| 66 |
right, left = signed32(stack.pop()), signed32(stack.pop())
|
| 67 |
condition = {'EQ': left == right, 'NE': left != right, 'LTI': left < right,
|
scripts/test_auth_announcements.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Table targets, real QVM recipient traces and fail-closed arithmetic checks."""
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import unittest
|
| 6 |
+
from analyze_auth_announcements import recover_table
|
| 7 |
+
from analyze_deferred_auth import trace_slice
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AuthAnnouncementTests(unittest.TestCase):
|
| 11 |
+
@classmethod
|
| 12 |
+
def setUpClass(cls):
|
| 13 |
+
cls.report = json.loads(Path('docs/research/auth-announcements.json').read_text())
|
| 14 |
+
|
| 15 |
+
def test_status_labels_and_signed_defaults(self):
|
| 16 |
+
rows = self.report['synthetic_label_traces']['status']
|
| 17 |
+
self.assertEqual([r['input_value'] for r in rows], list(range(-2, 4)))
|
| 18 |
+
self.assertEqual([[l['text'] for l in r['selected_labels']] for r in rows],
|
| 19 |
+
[[' - ^1ERROR'], [], [' - ^7no account'], [' - ^1auth server disabled'],
|
| 20 |
+
[' - ^1this server is banned'], [' - ^1ERROR']])
|
| 21 |
+
self.assertEqual(rows[1]['empty_string_writes'], [{'address': 0x40000000+40, 'value': 0}])
|
| 22 |
+
|
| 23 |
+
def test_role_values_and_empty_label(self):
|
| 24 |
+
rows = self.report['synthetic_label_traces']['role']
|
| 25 |
+
self.assertEqual([r['input_value'] for r in rows], list(range(-2, 10)))
|
| 26 |
+
self.assertEqual([r['selected_labels'][0]['text'].split('^')[-1][1:] for r in rows[2:-1]],
|
| 27 |
+
['Disabled', 'Registered', 'Friend', 'Member', 'Referee', 'Admin', 'Head Admin',
|
| 28 |
+
'Accounts Admin', 'Frozen Sand Developer'])
|
| 29 |
+
self.assertFalse(rows[1]['selected_labels'])
|
| 30 |
+
self.assertTrue(all(l['capacity'] == 128 for r in rows for l in r['selected_labels']))
|
| 31 |
+
|
| 32 |
+
def test_recipients_and_diagnostic_path(self):
|
| 33 |
+
rows = self.report['synthetic_recipient_traces']
|
| 34 |
+
self.assertEqual([[c['target'] for c in r['calls']] for r in rows],
|
| 35 |
+
[[70458, -18], [70458, -18], [5123], [70458, -18],
|
| 36 |
+
[70458, -18], [70458, -18], [], [70458, -18], [70458, -18]])
|
| 37 |
+
self.assertEqual([r['calls'][-1]['args']['8'] for r in rows if r['calls'] and r['calls'][-1]['target'] == -18],
|
| 38 |
+
[2, 2, 3, -1, -1, -1, -1])
|
| 39 |
+
self.assertEqual(rows[4]['calls'][0]['args']['8'], 68676)
|
| 40 |
+
self.assertEqual(rows[5]['calls'][0]['args']['8'], 68513)
|
| 41 |
+
|
| 42 |
+
def test_flag_scan_and_print_service_chain(self):
|
| 43 |
+
refs = self.report['constant_3200_references']
|
| 44 |
+
self.assertEqual([(r['function_entry'], r['instruction']) for r in refs],
|
| 45 |
+
[(174399, 175006), (174399, 175013)])
|
| 46 |
+
wrapper = self.report['support_functions']['2294']['instructions']
|
| 47 |
+
self.assertTrue(any(a['opcode'] == 'CONST' and a['operand'] == -1 and b['opcode'] == 'CALL'
|
| 48 |
+
for a,b in zip(wrapper,wrapper[1:])))
|
| 49 |
+
|
| 50 |
+
def test_native_broadcast_boundary_and_length_cap(self):
|
| 51 |
+
for n in self.report['native'].values():
|
| 52 |
+
self.assertEqual(n['trap_17']['numbers_sharing_target'], [17])
|
| 53 |
+
self.assertIn('$-0x1', '\n'.join(n['trap_17']['first_instructions']))
|
| 54 |
+
branch = '\n'.join(i['line'] for i in n['minus_one_branch'])
|
| 55 |
+
self.assertIn('xorl\t%edi, %edi', branch)
|
| 56 |
+
self.assertIn('<SV_SendServerCommand>', branch)
|
| 57 |
+
lines = '\n'.join(i['line'] for i in n['graphs']['SV_SendServerCommand']['instructions'])
|
| 58 |
+
self.assertIn('$0x3fe', lines)
|
| 59 |
+
self.assertIn('<SV_AddServerCommand>', lines)
|
| 60 |
+
|
| 61 |
+
def test_invalid_jump_table_bounds_and_targets(self):
|
| 62 |
+
ins = [{'opcode': 'ENTER'}] + [{'opcode': 'NOP'}]*4
|
| 63 |
+
header = {'data_length': 4, 'data_offset': 0}
|
| 64 |
+
for base, data in [(-4, b'\0'*4), (4, b'\0'*8), (0, b'\xff'*4), (0, b'')]:
|
| 65 |
+
with self.assertRaises(ValueError):
|
| 66 |
+
recover_table(data, header, ins, base, 0, 0, 0, 5)
|
| 67 |
+
|
| 68 |
+
def test_bounded_signed_division_and_shift(self):
|
| 69 |
+
def evaluate(left, right, op):
|
| 70 |
+
ins = [{'index': i, 'opcode': code, 'operand': value} for i,(code,value) in
|
| 71 |
+
enumerate([('LOCAL', 8), ('CONST', left), ('CONST', right), (op, None), ('STORE4', None)])]
|
| 72 |
+
return trace_slice(ins, 0, 5, {}, {})['memory'][0x40000008]
|
| 73 |
+
self.assertEqual(evaluate(-7, 3, 'DIVI'), -2)
|
| 74 |
+
self.assertEqual(evaluate(7, -3, 'DIVI'), -2)
|
| 75 |
+
self.assertEqual(evaluate(-7, -3, 'DIVI'), 2)
|
| 76 |
+
self.assertEqual(evaluate(-1, 2, 'LSH'), -4)
|
| 77 |
+
for left,right,op in [(1,0,'DIVI'), (-(2**31),-1,'DIVI'), (1,32,'LSH')]:
|
| 78 |
+
with self.assertRaises(ValueError):
|
| 79 |
+
evaluate(left,right,op)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == '__main__':
|
| 83 |
+
unittest.main()
|