Files
psxrecomp/docs/SEGMENT_AWARE_CODE.md
Shokunin b3b0960044 docs: record rollout PR B (runtime_pc) in the segment-aware design
docs/SEGMENT_AWARE_CODE.md:
- §3.1 names emitter functions instead of line numbers, adds the
  delay-slot jalr link and the VSync-query hook, and moves cosim_* out of
  the identity keys.
- §5.2 records what PR B routes through runtime_pc(), what keeps the
  compile address, the code segment's default (the image's own), and
  what the tests cover (the dispatch rows wait for PR C).
- §5.4 notes that D's variants must tail-transfer jump-table cases in
  another segment.
- §5.6 records that B routes emit_pre_icache's uncached test.
- §7.1 lists the two new regression guards; KNOWN_GAPS stays at 10.
- §7.2 adds the probe's interpreter and compiled columns next to
  Beetle's.
- §8 records B's validation.
- §9 records the re-keyed store-PC keys and their SCPH-1001 gate, the
  filters' Beetle divergence, and the confirmed game-syscall EPC gap
  (its own change).

FAITHFUL_TIMING_PLAN and ACCURACY_BURNDOWN get the dated entries; the
burndown also records the RAM 0x0-0xF boot-scratch divergence from
Beetle. STRING_TRANSLATION's note that attributed GP0 quads to ROM
0xBFC38B1C now names the runtime key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:12:27 -07:00

47 KiB
Raw Permalink Blame History

Segment-aware code

Status: design proposal (2026-09-28). The four owner decisions in §10 were settled on 2026-09-29; each follows this document's recommendation. Based on master. It was first stacked on RetroPortingToolKit/psxrecomp#417 (fix/overlay-segment-alias), which merged on 2026-09-29 together with #418 (mult/div deadlines) and #420 (store-PC forwarding, ABI v24). Rollout PR A merged as #429 on 2026-09-29 (§8). This change adds only this document and an acceptance test; it does not change behaviour. Rollout PR B (refactor/emitter-runtime-pc, stacked on this design) implements §5.2.

Acceptance test: recompiler/tests/test_segment_aware_codegen.py (ctest segment_aware_codegen). Synthetic EXE: tools/segment_testrom/gen_segment_exe.py.

1. Summary

A PS1 PC carries a segment:

  • KUSEG 0x0xxxxxxx
  • KSEG0 0x8xxxxxxx (cached)
  • KSEG1 0xAxxxxxxx (uncached)

The three map the same physical RAM. The CPU keeps the segment in:

  • every link value;
  • every EPC;
  • every I-cache tag.

KSEG1 also changes what each fetch costs. Compiled code bakes a single segment into all of these, so it is only correct for PCs in that segment.

#417 made overlay shards fail closed for other segments. Three problems remain:

  1. KUSEG-linked EXEs. Static game code for these executables runs at KUSEG PCs through KSEG0-baked bodies (Kula World SCES-01000, Alien Resurrection SLUS-00633).
  2. Overlay code at KUSEG or KSEG1. Overlay code that genuinely executes at KUSEG or KSEG1 is interpreted forever, because nothing records or compiles its segment.
  3. Uncached fetch charging. Compiled KSEG1 code charged the uncached +4 fetch only at cache-line leaders; Beetle and the interpreter charge it for every instruction. In the BIOS ROM, 5,477 of the 9,592 KSEG1 instruction sites in OpenBIOS were undercharged. PR A (#429) fixed that in both emitters (§5.6). Game code still has no KSEG1 bodies to charge (§5.4).

Recommendation: per-segment compiled variants, not segment-relative emission.

  • Code identity becomes the full runtime virtual address: segment plus physical address.
  • The emitter takes the segment as a compile-time constant. This generalizes the BIOS emitter's existing relocate_ra.
  • A body exists for each (segment, entry) that is actually executed:
    • static code: the EXE's link segment, plus variants requested with segment-qualified seeds;
    • overlays: the segments the capture observed.
  • A PC with no body for its segment is a loud dispatch miss. It never runs another segment's body.
  • Uncached variants charge fetch on every instruction.

For every existing KSEG0 title the emitted code is byte-identical, so the static fast path costs nothing. Segment-relative emission would cost +3.8 % code size for the constants alone, and +27.6 % with exact KSEG1 charging (measured on R4, §6).

2. What the segment changes: the Beetle model

Beetle is the oracle (CLAUDE.md §2). The citations below are to libretro/beetle-psx-libretro mednafen/psx/cpu.c @ a7f0811. The in-tree transcription, runtime/src/psx_icache.c, cites the older in-tree cpu.cpp:534-601; the semantics are the same.

Architectural value Beetle Depends on segment?
Link of jal/jalr/bgezal/bltzal GPR[31] = new_PC, i.e. PC+8 (DO_BRANCH, 1012-1031) yes: PC's segment
j/jal target ((PC+4) & 0xF0000000) + (target << 2) (JAL 1810-1818) preserves PC[31:28]
Branch target PC-relative preserves the segment inside any RAM window
EPC CP0.EPC = PC (−4 in a delay slot) (CPU_Exception, 846-850) yes
I-cache hit ICache[(addr & 0xFFC) >> 2].TV == addr: the full virtual address (744) yes: KUSEG and KSEG0 aliases never share a line
Uncached fetch addr >= 0xA0000000 or BIU bit 11 clear: +4 on every fetch, no fill, clears the load give-back (746-763) yes: KSEG1
Cached miss +3, plus 1 per word refilled to the line end; tags written with the full address (764-831) yes
Data loads and stores addr_mask[addr >> 29]: KSEG0/KSEG1 reduced to physical no

The interpreter (dirty_ram_interp.c) already follows every segment row: it links with pc + 8 (2019), computes j targets from pc + 4 (1905), and fetches at cpu->pc (1554). It does not model the BIU cache-disable case (§9).

Invariant used by this design. Direct control flow never changes segment:

  • j/jal keep PC[31:28];
  • branches stay within ±128 KiB of the PC.

Only indirect transfers switch segment: jr/jalr, exception entry, and the return through EPC. All three already go through dispatch with the full PC. So a body compiled for segment S only ever reaches other S bodies by direct edges.

Oracle deviation (decided 2026-09-29, §10). Beetle's own comment (719-730) says hardware clears bit 31 of a KSEG0 fetch address before the tag compare and the tag write. On hardware, KUSEG and KSEG0 aliases therefore do share lines. I-cache tags follow the oracle, as psx_icache.c does today. The acceptance test pins this: its Beetle model and psx_icache.c must agree. Moving to hardware semantics would be a separate, oracle-backed change. §5.6 keeps the tag computation in one place so that change stays one line. The hardware difference is recorded under ACCURACY_BURNDOWN axis 4 ("KUSEG/KSEG0/KSEG1 mirroring").

3. Where psxrecomp loses the segment today

3.1 Baked PCs in the game and overlay emitter (code_generator.cpp)

Before PR B, every one of these sites wrote the compile address, which is always KSEG0. The emitter functions are named instead of line numbers, which move with every change.

Site Emitter R4 static count
cpu->gpr[31] = 0x…u (jal, jalr, bgezal, bltzal links) and a jalr link in a branch delay slot translate_basic_block, translate_instruction 5,373
psx_icache_fetch(cpu, 0x…u) (fetch tags) translate_basic_block (emit_pre_icache) 61,181
psx_check_interrupts_at(cpu, 0x…u) (resume PC → EPC) emit_interrupt_check, ~20 callers 30,596
cpu->pc = 0x…u; return; (CPS exits, stale-static guard, image-edge tail transfer) translate_basic_block, generate_function, generate_alias_group, emit_stale_static_guard 6,927
g_debug_last_store_pc = 0x…u (before every sb/sh/sw/swl/swr/swc2) translate_instruction 22,936
CPS continuation keys case 0x…u: goto block_… generate_function, generate_alias_group —
Reserved-instruction EPC cpu->cop0[14] = 0x…u translate_basic_block —
psx_slice_block(cpu, 0x…u, …) (the interpreter resumes here) translate_basic_block —
psx_vsync_query_hle_enter(cpu, 0x…u, …): load_accel.c bases fetch tags, store-PC stamps and resume PCs on it generate_function — (R4 configures none)

The R4 figures come from all 50 generated shards: 2,994 functions and 141,335 emitted instruction sites.

The store-PC stamp is guest-affecting, not a debug hook. Its name and the emitted extern comment describe debug attribution, but the runtime reads it in every build:

  • memory.c psx_write_word_raw drops a word store to RAM 0x0-0xF when the stamp equals one of a list of exact PCs. An opt-in Tomba card filter (PSX_TOMB_CARD_EVCB_PROTECT) keys on it the same way.
  • memory.c's GP0 write path compared it with 0xBFC38B1C before PR B (§9 has the re-key), a BIOS store to GP0. On a match it hands gpu_set_gp0_source() a RAM source key taken from $a0. No preprocessor gate covers this, and main.cpp binds debug_cpu_ptr at startup (debug_server_set_cpu), so it also runs in every build. The resulting gp0_cmd_source_addr feeds the opt-in presentation paths (widescreen prim matching, the ws_* code in gpu.c, geometry/texture correction, mod texture keys) and prim-ring diagnostics. The faithful rendering path does not branch on it.
  • The interpreter stamps the full executing PC (dirty_ram_interp.c). A compiled body that stamps a different segment than the one it runs in can make a filter or key match in one execution path and miss in the other.
  • #420 (fix/fingerprint-guest-facts, ABI v24, merged 2026-09-29) makes overlay shards write the host's copy in every build. Before it, overlay stores wrote a private copy, so after an overlay store the filters saw the PC of an older store.

So the stamp is a baked PC like the others in the table, and §5.2 routes it through runtime_pc().

Two other groups carry PCs but are identity keys, not architectural state:

  • debug and identity hooks: debug_server_* (the runtime masks debug_server_cyc_observe's argument to physical), psx_mod_function_entry, psx_datashard_enter and psx_native_bad_entry's owner;
  • .ranges manifests.

The cosim_* hooks (the PSX_COSIM build only) record the executing PC at their checkpoints, as the interpreter does, so PR B routes them like the table.

The dispatch file (main_psx.cpp, psxrecomp_game_main):

  • keys its table by KSEG0 address and looks it up with the physical address (psx_game_find_entry, want = addr & 0x1FFFFFFF);
  • then sets cpu->pc = entry->resume_pc, which is a KSEG0 constant.

A KUSEG or KSEG1 PC therefore enters the KSEG0 body and continues in KSEG0. test_kuseg_dispatch_lookup.py currently asserts exactly this alias behaviour; PR C in §8 changes it.

3.2 KUSEG-linked EXEs

ps1_exe_parser.cpp:286-301 rewrites the header's KUSEG addresses to KSEG0 in place and records no original segment. It also rejects a KSEG1 entry (158). Three things follow:

  • The whole game compiles for KSEG0 while hardware runs it at KUSEG. Every link it saves on the stack and every EPC is 0x8… instead of 0x0…. Every fetch tag differs from the one the BIOS, the interpreter and Beetle use.
  • Seeds in the game's own segment are silently dropped. main_psx.cpp:799 range-checks seeds against the normalized load_address, so a seed written as 0x000100E4 is ignored. It must be written 0x800100E4.
  • R4 is linked at KSEG0 and is unaffected.

3.3 Aliases

A KSEG1 or KSEG0 alias of static text resolves to the same body (§3.1). The uncached cases are rare but real: code that ORs 0xA0000000 into a function address to run it uncached, and cache-maintenance trampolines. Those run with the wrong fetch cost and the wrong links. There is no way to request a body for a second segment: a segment-qualified seed is folded into the KSEG0 body or dropped.

Measured in the BIOS (2026-09-29). SCPH-1001 enters its relocated kernel through the uncached alias 0xA0000500. In #429's Beetle gate (§7.2) Beetle charges the four instructions there as uncached fetches, 20 cycles; native runs the cached body and charges 11. That is the −9 cycles native − Beetle at SCPH-1001's kernel entry and first kernel calls. The BIOS emitter needs a KSEG1 variant of that entry to close it, which PR D (§8) provides.

3.4 Overlays (after #417)

Segment is lost at every stage:

  • Capture: it records bytes and entry PCs per physical word (g_dirty_ram_{exec,dispatch}_pc_bitmap). It writes every PC back out as PSX_OVERLAY_CODE_SEGMENT | offset (overlay_capture.c 365, 420-446).
  • Interpreter dispatch: it sees the full PC, but drops the segment at dirty_ram_interp.c:2814.
  • Cache names and manifests:
    • filenames and namespaces start from the physical address ({phys}_{crc}, ov_{phys}_…);
    • .ranges F entries are forced to KSEG0 (overlay_loader.c:942);
    • exports must be named func_{KSEG0 entry};
    • candidates are indexed by physical address.
  • Gate: #417's gate (overlay_loader.c:3690) interprets every non-KSEG0 PC.

In R4 that is about 27 dispatches per frame: OpenBIOS enters its RAM patch slots 0x0000281C and 0x0000357C at KUSEG. Nothing ever asks for a KUSEG shard.

3.5 Uncached fetch in compiled code

Fixed by #429 (§5.6). This section records the gap as it was on master before that PR.

Both emitters emitted psx_icache_fetch only at cache-line leaders:

  • block leaders, jump-table targets, and addr & 0xC == 0 (the leader test, now code_generator.cpp:1903 and full_function_emitter.cpp:790).

The stated reason is that "intra-line followers reached by fall-through are guaranteed hits". That holds for cached segments only. Every KSEG1 fetch misses and costs +4 (§2).

The BIOS main ROM runs in place at 0xBFC0… (KSEG1). In OpenBIOS, 5,477 of its 9,592 KSEG1 instruction sites had no fetch call. Each was:

  • 4 cycles short;
  • missing the load give-back clear.

The interpreter charges every one. Compiled and interpreted ROM code therefore disagreed, against the "one shared per-instruction cost" rule (CLAUDE.md RULE −1). Ruler #2 and ruler #1 both run in cached RAM, so neither caught it.

4. Options

A. Segment-relative emission

One body per function takes the segment at run time: from a field set by dispatch, or from a parameter. It emits seg | phys at every site in §3.1.

  • Code and speed. Every baked constant becomes an OR with a live register. That is about 127k sites in R4 (22,936 of them store-PC stamps), before continuation keys and switch tables. CPS continuation switches can no longer be case constants; they need a physical-address switch plus a segment check.
  • Exact KSEG1 charging. The body cannot know statically whether it is uncached. So each non-leader instruction (about 80k sites in R4) needs a run-time if (seg >= KSEG1) fetch test. A block-level surcharge is not exact, because each uncached fetch clears the load give-back between instructions.
  • ABI. Every generated function needs the segment source:
    • direct calls must carry it;
    • dispatch must scope it;
    • the overlay DLL ABI (PSX_OVERLAY_ABI_TAG) and mod entry hooks change.
  • Measured cost (§6): +3.8 % __TEXT for the constants, +27.6 % with exact KSEG1 charging.
  • Benefit: one body serves any segment, and no variant set has to be chosen.
  • The emitter bakes the segment as a constant, as it does now, but reads it from a per-compile code_seg instead of assuming KSEG0.
  • A body is compiled for each (segment, entry) the program executes.
  • Uncached variants emit a fetch charge on every instruction; cached ones keep the leader rule.

Costs:

  • Code grows only by the variants actually requested. These are almost always small closures, such as a cache-flush trampoline.
  • The variant set must be known. The segment-miss ring (§5.5) and capture (§5.7) supply that evidence.

Reasons to choose B:

  • It keeps the fast path exactly as it is for the home segment.
  • It keeps each body's cost model static, so it can be checked against Beetle per instruction.
  • It follows the model the BIOS emitter already uses: per-window runtime PCs through BiosAddressModel::runtime_pc.
  • Option A spends 4-28 % of every title's code on a case most titles never hit.

5. Design

5.1 Code identity is the runtime virtual address

A compiled body is identified by seg | phys:

  • func_ names use it: func_00010000 for a KUSEG-linked entry, func_A00100F0 for a KSEG1 variant.
  • Direct C calls and CPS exits then resolve to the same-segment variant with no extra lookup.
  • KSEG0 names do not change.

5.2 Emitter: one runtime_pc() for every baked PC

  • CodeGenerator gets runtime_pc(compile_addr) = code_seg | (compile_addr & 0x1FFFFFFF), modelled on bios_runtime_pc.
  • Every site in §3.1 goes through it, the store-PC stamp included. That is 9 helpers and about 40 format sites, plus 8 store-PC format sites.
  • The helper also covers generate_alias_group bodies and the dispatch emitter's rows (addr, resume_pc).
  • Identity keys (block labels, .ranges) may keep the compile address. The store-PC stamp is not an identity key (§3.1).
  • code_seg defaults to the segment of the image's load address. The EXE parser folds KUSEG headers to KSEG0 until §5.3, so that is KSEG0 for every title today, and runtime_pc(addr) == addr for every address in the image.
  • Acceptance: regenerating any KSEG0 title is byte-identical. That proves the refactor has zero fast-path cost. Closes nothing in the ledger on its own; enables §5.3-5.6.

Implemented in PR B. CodeGenerator::set_code_segment() and runtime_pc(). Routed through it:

  • links (jal, jalr, bgezal/bltzal, and a jalr in a branch delay slot, which translate_instruction emits) and the call contract's return PC;
  • fetch tags and emit_pre_icache's uncached test (§5.6);
  • interrupt resume PCs and the reserved-instruction EPC;
  • CPS exit PCs, continuation case keys (function and alias bodies) and the alias entry keys;
  • call_by_address and stale-static-guard targets, the slice resume PC and the cosim_* PCs;
  • store-PC stamps, including the widescreen-backdrop and persisted-option store sites;
  • the VSync-query hook's first argument. load_accel.c derives fetch tags, store-PC stamps and resume PCs from it, so it is a PC, not an id. Its hand-timed body charges cached line-leader fetches only, so the hook is not emitted for an uncached code segment: a KSEG1 body runs its own instructions, each charged (§5.6). The dispatch-side psx_vsync_query_hle_try compares the full PC exactly, so another segment never takes it (nothing calls psx_vsync_query_hle_configure today);
  • the PC psx_unknown_dispatch reports from a data stub;
  • dispatch rows (addr, and resume_pc when it is a PC).

func_/block_ names, .ranges, debug_server_log_call_entry, the mod and data-shard hook ids and psx_native_bad_entry's owner keep the compile address. So does debug_server_cyc_observe: the game emitter passes the compile address and the runtime masks it to physical.

Jump-table case values are data words, the PCs the jr reaches, and are emitted as read. Each case continues in the current body. That is exact while every table value is in the body's own segment, which holds for every title before PR D. A variant (§5.4) must tail-transfer a case whose segment differs from its own; D adds that.

The BIOS side is StrictTranslator::translate(d, runtime_pc): the store-PC stamp, the syscall EPC and the break and unaligned-access PCs use the runtime PC, while the psx_ldd_ temporaries and terminator_target keep the ROM address. The full-function emitter passes relocate_ra() to every translate() call, including orphaned delay slots and the two calls that read only terminator metadata, and publishes the runtime PC on a fallthrough. The seven memory.c keys are re-keyed in the same commit, and gated so that each still matches only its own SCPH-1001 instruction (§9).

Tests:

  • emitter_runtime_pc_test compiles a small program in CPS, CPS-overlay and legacy mode, through generate_all_functions. With the code segment left at its default, set to KSEG0, set to KUSEG and set to KSEG1, it checks each game-emitter PC class above except the dispatch rows, the identity keys, and that KUSEG output differs from KSEG0 only in constant segments. The program reaches every emission path listed above (the test fails if one stops being emitted), including split conditional branches, alias groups, data stubs, the image edge, the backdrop and persisted-option store sites and a jalr in a delay slot. It also checks that a KSEG1 code segment charges exactly what a KSEG1 image charges. For the translator it checks every PC-bearing form, the deferred loads and both syscall forms (ctest runs it a second time with PSX_CPS=0).
  • The dispatch rows (main_psx.cpp) are routed but not tested here: until PR C they equal the compile address for every real executable. C's ledger checks query them.
  • A single-site mutation sweep (revert one route, rebuild, run the unit test in both PSX_CPS modes and the ledger) kills 77 of the 80 routes. The three survivors are the two dispatch-row routes and the continuation key for a call return in the middle of a block, which the CFG analyzer does not produce (it starts a block at every call return; R4's 2,994 functions have none).
  • The ledger gains two regression guards (§7.1).

The BIOS emitter's precedent has holes to close in the same pass:

  • the fallthrough cpu->pc = next_addr (full_function_emitter.cpp);
  • the strict_translator syscall, break and unaligned-access PCs, which use the ROM address;
  • the strict_translator store-PC stamp, which also uses the ROM address. Moving it changes which memory.c keys match: the store filters (RAM 0x0-0xF and the opt-in Tomba EvCB filter) and the GP0 source key (§3.1). Seven of those keys, six store-filter keys and the GP0 key 0xBFC38B1C, are ROM addresses of code that runs relocated, so they must be re-keyed to runtime PCs in the same change (§9).
  • PS1Executable records link_segment = initial_pc & 0xE0000000.
  • It keeps physical helpers for byte addressing instead of rewriting the header.
  • Load address and entry must share the segment:
    • KUSEG and KSEG0 are accepted;
    • KSEG1 is accepted and compiles as an uncached home segment.
  • The game emitter runs with code_seg = link_segment.
  • Seeds are range-checked by physical address, so 0x000100E4 is a valid home seed.

Closes link-segment, fetch-tag-segment, irq-resume-segment, resume-pc-segment, store-pc-segment, home-seed-accepted and alias-fetch-coherence.

5.4 Variant requests and closure

  • A seed whose segment differs from the link segment requests a variant, for example 0xA00100F0. This reuses the seeds file; no new configuration surface is needed. Decided 2026-09-29 (§10): segment-qualified seeds are the only request mechanism, with no game.toml table.
  • The recompiler compiles the variant's direct-edge closure in that segment:
    • direct calls;
    • tail jumps;
    • CPS continuation targets.
  • By the invariant in §2, that closure is everything the variant can reach without dispatch.
  • Bodies are emitted once per (segment, entry). Cached variants use the leader rule; KSEG1 variants use §5.6.
  • A jump-table case whose value lies in another segment tail-transfers to dispatch instead of continuing in the variant (§5.2).

Closes segment-variants.

5.5 Dispatch is exact; a segment miss is a dispatch miss

  • The table is keyed by the full VA. psx_game_find_entry keeps its physical-word index. The index now points at the first row for that word, and the lookup requires row.addr == addr.
  • Rows for one physical word are adjacent. A title with no variants has one row per word, which is today's shape.
  • resume_pc is per-variant, so cpu->pc = entry->resume_pc is correct by construction.
  • A physical hit with no row for the PC's segment is a segment miss:
    • it is counted and recorded in a TCP-visible ring with the full PC;
    • it takes the existing clean-text-miss path (dirty_ram_interp.c clean_game_text_miss).
  • Resolving it follows the project rule for any dispatch miss: add the segment-qualified seed, then regenerate.
  • It never runs another segment's body.
  • Decided 2026-09-29 (§10): a segment miss in static game code interprets loudly until the title is regenerated. It does not fail fast.

Closes segment-miss. psx_call_contract's segment-masked return check (cpu_state.h:312) can become exact in the same PR.

5.6 Per-instruction fetch charging for uncached code

Landed in PR A, #429 (2026-09-29). The rule: an instruction whose runtime PC is uncached is charged a fetch of its own.

  • Cached code keeps the leader rule, which is exact for it: the ledger's model check proves that against Beetle for the probe block.
  • Both emitters emit psx_icache_fetch(cpu, pc) before every instruction for which psx_fetch_uncached(pc) holds (psx_instr_cost.h, pc >= 0xA0000000). psx_icache.c uses the same predicate: such a fetch never hits, so it clears the load give-back, adds +4 and fills nothing, as Beetle does. The A0/B0/C0 call-vector stubs charge one fetch per executed word.
  • The same PR fixed the BIOS emitter's cached line-start test, which used the ROM address. It now tests the runtime PC (relocate_ra): OpenBIOS copies its kernel from ROM 0x1FC1E4D4 to RAM 0x500, which shifts bits[3:0] by 4, and 1,152 kernel instruction sites were charged one instruction early.
  • The tag compare stays in psx_icache.c. Tags follow Beetle (decided, §10), and this is the one place a later, oracle-backed move to hardware tags (§2) would change.

Where it applies:

  • BIOS emitter: relocate_ra(rom) is uncached for the ROM run in place. Done in #429, which closed bios-kseg1-fetch-charge.
  • Game and overlay emitter: the predicate was already there, but emit_pre_icache tested the compile address, which is always KSEG0. §5.2 routes two things through runtime_pc(): the psx_fetch_uncached() argument and the fetch tag (done in PR B). Routing only the tag would charge a KSEG1 variant at line leaders alone. With both routed, a KSEG1 variant (§5.4) is charged per instruction with no further emitter change; emitter_runtime_pc_test checks that a KSEG1 code segment charges exactly what a KSEG1 image does. kseg1-fetch-charge stays open until PR D adds those variants.
  • Interpreter: unchanged; it already charges every fetch.

BIU bit 11 (cache disable) makes every fetch uncached in Beetle. It is a run-time state, not a segment, and is listed under §9.

5.7 Segment-aware overlay capture and cache keys

The bytes are segment-free; execution is not. So the byte identity (region, CRC, per-function code_crc, pair dedup) stays physical, and the segment joins only the entry identity and the compiled artifact key.

Capture

  • g_dirty_ram_dispatch_pc_bitmap keeps its meaning (dispatched in any segment), and region building keeps using it.
  • Add one sibling bitmap per segment: KUSEG, KSEG0 and KSEG1.
  • The siblings are set at the interpreter dispatch that already sets the bit, from the full addr before dirty_ram_interp.c:2814 masks it. The cost is one bit set per interpreted dispatch, not per instruction. Host memory is 3 × RAM/32 (192 KiB retail, 768 KiB in 8 MiB mode).
  • The execution bitmap needs no segment: direct edges keep the entry's segment.

Capture JSON

  • Schema v3 adds "dispatch_entry_segments": {"kuseg": [...], "kseg0": [...], "kseg1": [...]}.
  • If it is absent, entries are KSEG0 only, so every existing v2 capture stays valid.
  • The #417 counter segment_alias_interp becomes the trigger: a miss records its entry into these bitmaps, and the next compile builds the shard.

Compile (compile_overlays.py)

  • One shard per (region, segment with entries).
  • make_psxexe(seg | phys, …) relies on §5.3's parser.
  • _canonical_guest_addr and the | 0x80000000 sites become seg | phys.
  • The static-overlay generator accepts non-KSEG0 variants: it emits the segment in its table and matches exactly, replacing the #417 rejection at 2535.

Cache keys (layout decided 2026-09-29, §10)

  • KSEG0 artifacts keep today's path and names, so no existing cache invalidates.
  • Other segments go in a per-segment subdirectory of the same cache tag: …/cg<N>_<hash>_gc<hash>_f<n>/seg-kuseg/{phys}_{crc}.{dll,so} (and seg-kseg1/).
  • The filename grammar (psx_overlay_cache_name_parse, 8_8 or 8_8_8) does not change.
  • Static namespaces add s<seg >> 29> for non-KSEG0 shards (ov_s0_{phys}_… for KUSEG, ov_s5_… for KSEG1), so static variants can link side by side.
  • Exports are func_{VA} (§5.1), so they are distinct per segment.
  • pair_id hashes the C source, so variants never pair-alias one another.
  • A variant shard stamps its own segment's store PCs (§5.2). This relies on #420 (ABI v24), which forwards overlay stamps to the host's g_debug_last_store_pc in every build, so the memory.c filters see them.

Manifest

  • .ranges gains S <segment>; if it is absent, the segment is KSEG0.
  • F entries carry the full VA and must match S. parse_manifest stops forcing KSEG0 (942).
  • R lines stay physical extents: they validate bytes, not execution.

Loader

  • Candidates keep the physical index and gain a seg field.
  • The #417 gate generalizes from "PC is KSEG0" to "a candidate for this physical address has seg == pc & 0xE0000000".
  • Other aliases still interpret. Rule 18 permits that for runtime-installed code, and it is counted and fed back to capture.
  • overlay_idle_note_is_internal_or_return (2308) compares full VAs.

Docs: update AOT_OVERLAY_PLAN's "canonical KSEG0 entries" contract and AOT_SHARDING's KSEG0 window. OVERLAY_CACHE_V2's per-function key already names guest_entry_vaddr; this design makes that VA include the segment.

Expected R4 effect: the OpenBIOS KUSEG patch slots are captured as KUSEG entries of the kernel page and get a KUSEG shard. segment_alias_interp drops to 0 on a warm cache.

6. Cost for the static fast path

Option A (segment-relative) Option B (variants), this design
Home-segment code +3.8 % __TEXT; +27.6 % with exact KSEG1 charging 0: byte-identical for KSEG0 titles
Per-instruction work an OR at each baked PC, plus a KSEG1 test at about 80k non-leader sites none added
Dispatch segment scoping on every dispatch one row.addr == addr compare per dispatch; with no variants, the same row count as today
ABI every generated function and the overlay DLL ABI unchanged (it relies on #420's v24 store-PC forwarding, which is independent of this design)
Extra code none the requested variant closures only
KSEG1 fetch fix runtime test everywhere a per-instruction charge only in KSEG1 bodies: 5,477 added call sites in OpenBIOS ROM code, paid only while ROM code runs

How the A numbers were measured

  • R4 shards 00, 17 and 33 (911,454 B __TEXT), compiled with the build's own command (Apple clang 21, arm64, -O3).
  • Rewritten in a scratch copy to load the segment once per function and OR it into every link, fetch-tag, IRQ-resume, CPS-exit and store-PC constant: 946,046 B (+3.8 %).
  • With if (seg >= KSEG1) fetch added before each non-leader instruction: 1,163,390 B (+27.6 %).
  • The first measurement (2026-09-28) left out the 1,261 store-PC stamps in these shards, because it treated them as debug-only. Without them the figures were 940,382 B (+3.2 %) and 1,159,826 B (+27.2 %).
  • This is a size measurement, not a timing one. The per-instruction test sits on the hottest path and is only exact if it runs every time.

Why B's figure is 0

  • It follows by construction, not measurement: a KSEG0-linked title (R4 included) is emitted with code_seg = KSEG0, which is today's output. PR B (§8) proves it by byte-identical regeneration.

What B adds at run time

  • One compare per dispatch.
  • The capture bitmaps (§5.7).
  • The recovered native coverage: the ~27 per-frame interpreted KUSEG alias dispatches in R4 become native.

7. Validation against Beetle

7.1 Acceptance ledger (this PR)

test_segment_aware_codegen.py builds the synthetic KUSEG-linked EXE and runs the real psxrecomp-game and psxrecomp-bios on it (OpenBIOS for the latter, 0.3 s). It checks three things:

  • Emitted PC constants in the bodies that dispatch picks for link-segment PCs.
  • Dispatch behaviour, using the emitted lookup compiled and queried:
    • home PCs;
    • requested variants;
    • unrequested aliases, which must miss.
  • Fetch cycles. The emitted fetch sequence runs through the real runtime/src/psx_icache.c. The result must equal the per-instruction sequence Beetle executes, computed by a Python transcription of ReadInstruction.

Model checks (must always pass)

  • psx_icache.c equals the Beetle transcription on:
    • cold then warm cached runs;
    • a KSEG0 → KUSEG alias refill;
    • repeated KSEG1;
    • partial refill.
  • The leader rule is exact for the cached probe block.
  • A mutation of the model to hardware bit-31 tags, or to a +5 uncached cost, fails.

Gap ledger. KNOWN_GAPS in the test lists each id with the section that closes it. The test passes only when the observed gaps equal the ledger. Today ten are open:

id observed today closed by
link-segment 7 link constants, e.g. 0x80010018 §5.3
fetch-tag-segment 30 fetch tags in KSEG0 §5.3
irq-resume-segment 4 resume PCs in KSEG0 §5.3
resume-pc-segment 10 exit PCs / continuation keys in KSEG0 §5.3
store-pc-segment 10 store-PC stamps in KSEG0, e.g. 0x8001000C §5.3
home-seed-accepted KUSEG seed loaded 0 of 1 §5.3
alias-fetch-coherence interp-then-compiled leaf: 22 cycles vs Beetle 11 (the #417 shape) §5.3
segment-variants 0xA00100F0 resolves to the 0x800100F0 body §5.4
segment-miss unrequested KSEG0/KSEG1 aliases resolve; KUSEG PCs resolve to KSEG0 rows §5.5
kseg1-fetch-charge no KSEG1 body (Beetle: 40 cycles for the 10-instruction run) §5.4 + §5.6

Each implementation PR removes the ids it closes. PR A (#429, §8) was the exception: it landed on master before this test did. This PR dropped its id, bios-kseg1-fetch-charge, when it was rebased onto it. The BIOS check stays as a regression guard: an OpenBIOS KSEG1 instruction without its own fetch reports that id again, as a new gap.

PR B closes no id. It adds two more regression guards, which are REGRESSION_GUARDS in the test:

  • bios-runtime-pc (§5.2): no store-PC stamp, syscall EPC, break or unaligned-access PC, or fallthrough PC in the OpenBIOS output names a ROM address inside a relocated window. Before B, 2,295 distinct PCs did (3,328 PC and site-class pairs, since a sh/sw carries both a stamp and an unaligned-access PC), all in the Kernel ramtext and Shell windows. The output only shows the translations OpenBIOS exercises, and most orphaned-delay-slot paths never inline a PC-bearing instruction there, so the guard also requires every StrictTranslator::translate() call in full_function_emitter.cpp to pass relocate_ra().
  • store-pc-keys-runtime (§9): no memory.c store-PC key lies inside one of SCPH1001.toml's relocated ROM windows. A key for relocated code must be the runtime PC of its ROM store, computed from the profile's windows as BiosAddressModel::runtime_pc() does, and must go through the gated scph1001_relocated_store(); a bare RAM key would also match other BIOSes and game code.

The overlay half (§5.7) gets its own acceptance case in runtime/tests/test_overlay_segment_gate.py:

  • a KUSEG-compiled fixture shard runs natively for its KUSEG PC;
  • the same shard is interpreted for the KSEG0 and KSEG1 aliases.

7.2 Oracle runs (implementation PRs)

The synthetic EXE is also an oracle probe. It writes into a results block at 0x00011000, outside the image:

  • the three link values;
  • the executing-segment link from each of the three probe_run entries;
  • a Timer 2 delta around each of those entries.

It then spins. Procedure:

  • Boot it from a tools/cycle_testrom-style disc (SYSTEM.CNF plus the EXE). Do not sideload it: Beetle's EXE loader forces a KSEG0 start address, so a sideloaded run executes at 0x8001… and every link comes back KSEG0. Under OpenBIOS a sideloaded run never reaches the program at all.
  • Read the block from Beetle, from the interpreter (PSX_FORCE_INTERP=1) and from the compiled build, and compare.
  • Expected values: links 0x00010018, 0x00010024, 0x00010038; segment probes 0x000100FC, 0x800100FC and 0xA00100FC. The KUSEG and KSEG0 T2 deltas are equal, and the KSEG1 delta exceeds them by the uncached fetch surcharge: 26 cycles for the 15 KSEG1 fetches (Beetle's fetch model: 60 uncached against 34 cold cached).

Beetle results (2026-09-29). psx-beetle built on macOS (docs/beetle-macos.md, from #431), disc boot, OpenBIOS and SCPH-1001:

  • The first run found a probe bug: the T2 subtraction sat in the second lw's load delay slot and read the stale register, and the difference was not masked to T2's 16 bits. Every delta was unusable. The generator now waits one nop and masks with andi 0xFFFF. It also starts each call sequence and probe_run on their own I-cache lines; before that, alignment alone made the deltas read 59/59/89.
  • Links and segment probes match the expected values above on both BIOSes.
  • T2 deltas: 56 / 56 / 82 cycles for KUSEG / KSEG0 / KSEG1 on both BIOSes. The KSEG1 surcharge is 26, exactly the fetch model's.
  • A cycle watch on the first layout, before these fixes, timed the pieces directly: 10 / 10 / 15 cycles from probe_run entry to getpc, and 8 / 8 / 10 from getpc back.

Interpreter and compiled columns (PR B, 2026-09-29). Both come from a probe runtime built like tools/cycle_testrom's: disc boot, LLE, with seeded seeded as 0x800100E4. The interpreter column needed a tooling fix: PSX_FORCE_INTERP=1 had stopped affecting clean game text. It only marks pages dirty, and the native-safety checks now decide by the bytes, so B restores it.

links segment probes T2 KUSEG / KSEG0 / KSEG1
Beetle 0x00010018/24/38 0x000100FC, 0x800100FC, 0xA00100FC 56 / 56 / 82
interpreter same same 56 / 56 / 82
compiled (B = base) 0x80010018/24/38 0x800100FC × 3 56 / 20 / 20
  • These results are the same on OpenBIOS and SCPH-1001.
  • A cycle watch shows the same split. From one probe_run entry to the next, Beetle and the interpreter take 90 and 90 cycles, and compiled code takes 90 and 54. From probe_run to getpc, Beetle and the interpreter take 10 / 10 / 15 and compiled code takes 10 / 1 / 1.
  • The compiled column is the expected set of gaps:
    • links in KSEG0 (§3.2, closed by C);
    • fetch tags in KSEG0, so the two alias runs hit the lines the home run filled, where Beetle refills or runs uncached (fetch-tag-segment, alias-fetch-coherence, C);
    • no KSEG1 body (D).
  • The compiled probe output is byte-identical before and after B.

Other runs

  • §5.6 BIOS fix (PR A, #429): done, passed. LLE boot (bios_hle = false) cycle parity against live Beetle to the shell, OpenBIOS and the owner's SCPH-1001 dump, the same image on both sides. Native − Beetle at the shell entry moves from −2,165,591 to −126 cycles (OpenBIOS) and from −7,049,462 to −454 (SCPH-1001). The residue comes from two known gaps that #429 does not cause or widen: IsC stores that do not invalidate the I-cache model (§9) and SCPH-1001's KSEG1 kernel entry (§3.3). Ruler #1 (SCPH-1001) matches pass for pass on all 64 passes, and ruler #2 matches on all 14 components.
  • KUSEG titles: regenerate Kula World and Alien Resurrection and smoke them. Use a warm/cold A/B with the #417 procedure (seeded, PSX_OVERLAY_AUTOCOMPILE_OFF=1, compare cyc/mc/sp plus an order-independent write sum).
  • KSEG0 titles: byte-identical regeneration (§5.2).

8. Rollout

Small PRs in this order. Each PR updates the ledger and FAITHFUL_TIMING_PLAN's log.

  • A: fix/uncached-fetch-per-insn (§5.6). Merged on 2026-09-29 as #429. Both emitters charge psx_icache_fetch before every instruction whose runtime PC is uncached, and the BIOS emitter's cache-line start test uses the runtime PC. Closed bios-kseg1-fetch-charge; this PR dropped the id when it was rebased onto A (§7.1). Beetle parity (LLE boot to the shell, §7.2): native − Beetle at the shell entry is −126 on OpenBIOS and −454 on SCPH-1001, down from −2,165,591 and −7,049,462. The residuals are the IsC gap (§9) and SCPH-1001's KSEG1 kernel entry (§3.3). The oracle recipe for macOS is docs/beetle-macos.md (#431).
  • B: refactor/emitter-runtime-pc (§5.2). No behaviour change for game code, including the store-PC stamps; proven by byte-identical regeneration of KSEG0 titles. The BIOS-emitter holes in §5.2 do change BIOS output. The store-PC one ships with the re-key of the seven memory.c keys: six store-filter keys and the GP0 source key (§9). Implemented 2026-09-29:
    • R4 regenerates byte-identically: 53 files, including the dispatch table, and all 26 overlay shard sources compiled from a race capture.
    • Every changed BIOS line is a runtime_pc() move: 2,334 lines in OpenBIOS and 12,501 in SCPH-1001. The dispatch tables are unchanged.
    • LLE boot against Beetle matches the previous build at every anchor: shell entry −126 on OpenBIOS and −454 on SCPH-1001, as in A's gate.
    • The probe's total cycles to its spin are unchanged on both BIOSes.
    • R4 fingerprints (12000 frames, cold and warm) agree on every column that does not hash store PCs. mmio (a judge column whose ordered hash includes the store PC) and the pc locator first differ at fingerprint frame 4 and stay different, as rolling hashes do. An ordered recording of PSX_RECORD_FRAME=3 (12,238 entries) holds the whole difference: 102 store PCs moved from ROM to runtime in OpenBIOS's Kernel ramtext window. PSX_RECORD_FRAME=4 (14,474 entries) is identical.
    • In an SCPH-1001 LLE boot, the GP0 source key fires for the same 5,596 commands in both builds.
  • C: feat/kuseg-linked-exe (§5.3, §5.5). Also rewrites the alias assertions in test_kuseg_dispatch_lookup.py: an alias with no body now misses. Closes eight ids: §5.3's seven and segment-miss (§5.5).
  • D: feat/segment-variants (§5.4). Closes segment-variants and kseg1-fetch-charge. Since #429, D only needs the KSEG1 variants: with B's runtime_pc(), #429's per-instruction rule charges them (§5.6), and kseg1-fetch-charge stays open until D lands. Also gives the BIOS emitter a KSEG1 variant of SCPH-1001's kernel entry 0xA0000500, which closes the −9 cycles left at that entry in #429's gate (§3.3).
  • E: feat/overlay-segment-keys (§5.7). Includes the runtime acceptance case, and the R4 warm-cache check that segment_alias_interp reaches 0.

9. Adjacent gaps (not in scope, recorded)

  • BIU bit 11 does not reach the fetch model. memory.c stores 0xFFFE0130 but psx_icache.c ignores it. Beetle charges +4 for every fetch while the cache is disabled (CPU_SetBIU, 484-505). This matters only for RAM code run with the cache off.

  • IsC stores are dropped (memory.c:1768). Beetle writes the I-cache tag and valid bits through them (WriteMemory_IsC_misc, 623-641), so a FlushCache in psxrecomp does not invalidate the I-cache model. #429's Beetle gate measured it: native − Beetle drops by 42 cycles at each of OpenBIOS's three boot flushes (−126 at the shell), and by 52 / 149 / 149 / 95 at SCPH-1001's four (−445, plus the −9 of §3.3, gives −454). Native keeps hitting lines that Beetle has invalidated. A local prototype that invalidates the model on IsC stores brings OpenBIOS to 0 through the shell and SCPH-1001 to −9 (the §3.3 kernel entry). It is planned as its own small PR.

  • BIOS store-PC keys are ROM addresses. The strict_translator stamp is the ROM address; the interpreter stamps the runtime PC. Seven memory.c keys lie in SCPH1001's relocated windows (bios/SCPH1001.toml). The runtime PC shown for each is what BiosAddressModel::runtime_pc() returns:

    • Kernel Part 2, which runs at 0x500: the RAM filter key 0xBFC10A00 (0x00000F00) and the Tomba key 0xBFC117E4 (0x00001CE4);
    • Shell, which runs at 0x80030000: the RAM filter keys 0xBFC3EEB4 (0x80056EB4), 0xBFC405E4 (0x800585E4), 0xBFC40788 (0x80058788) and 0xBFC41C50 (0x80059C50), and the GP0 source key 0xBFC38B1C (0x80050B1C, memory.c's GP0 write path).

    For these keys a filter fires, or the GP0 write gets a RAM source key, only while that code runs compiled. If the same code runs interpreted, the store lands and the GP0 source stays the register address. Routing the BIOS stamp through runtime_pc() (§5.2) without re-keying would silently disable all seven. Re-keying them to runtime PCs in the same change also makes the interpreted path agree.

    Re-keyed in PR B. The SCPH-1001 output stamps each of the seven runtime PCs exactly once, where it stamped the ROM key before; the four in-place ROM keys (0xBFC04E90, 0xBFC04EF0, 0xBFC05164, 0xBFC0D634) do not move. The GP0 key is live during boot: frames 1-600 of an SCPH-1001 LLE boot send 46,850 GP0 commands, 5,596 of them keyed, identically before and after.

    A RAM PC alone is ambiguous: another BIOS, or game code once the region is reused, can run a store at the same address, where the old ROM key could only match the compiled SCPH-1001 store. So each of the seven keys goes through scph1001_relocated_store(pc, rom), which matches only while the active image is SCPH-1001 (by CRC) and the RAM word at pc is still the ROM word it was copied from. That keeps each key to the one instruction it named before:

    • OpenBIOS, SCPH-101 and SCPH-5552 never match. SCPH-101 and SCPH-5552 have no address model, so their kernel (byte-identical to SCPH-1001's) and their shells run interpreted and stamp runtime PCs; an ungated RAM key would have started firing on them.
    • Game code at those addresses never matches, on any BIOS.
    • The one widening left is intended: SCPH-1001's own instruction now also matches when it runs interpreted, as it already did compiled.

    The filters themselves diverge from Beetle; PR B keeps them as they were. At the segment probe's spin on SCPH-1001 (disc boot, LLE), Beetle's RAM 0x0-0xB holds 00000003 275A0C80 03400008: the delay-loop scratch word and the second and third words of the exception-vector stub. Native holds zeros, because game start clears the low scratch (memory_clear_low_boot_scratch) and the filters drop the later stores: Beetle's write log shows the in-place ROM store 0xBFC0D634 writing word 0 after game start. That is ACCURACY_BURNDOWN axis 4 ("RAM 0x0-0xF boot scratch"), to be decided with oracle evidence on its own.

  • Syscall EPC in compiled code comes from cpu->pc (traps.c:1040). The BIOS emitter sets it; since PR B it sets the runtime PC. The game emitter does not set it.

    • Confirmed in PR B: under CPS a compiled game body runs with cpu->pc == 0, because the entry switch consumes it.
    • The game emitter also ignores psx_syscall's transfer result. The BIOS emitter returns on it.
    • A game syscall that reaches the BIOS exception handler (anything but Enter/ExitCriticalSection, which psx_syscall handles directly) would therefore record EPC 0 and continue inline.
    • R4 has two syscall sites, both Enter/ExitCriticalSection.
    • The fix is to emit cpu->pc = <runtime_pc>; if (psx_syscall(...)) return;, as the BIOS emitter does. That changes the generated code of every title with a syscall site, so it is its own oracle-backed change, not part of B's byte-identical refactor.

10. Decisions (2026-09-29)

The owner delegated the four open questions and accepted this document's recommendations. They are settled; implementation PRs follow them.

  1. I-cache tags follow Beetle, the oracle. Tags compare the full virtual address. Hardware clears bit 31 of KSEG0 fetches first; that difference is recorded in ACCURACY_BURNDOWN axis 4 (§2). A move to hardware tags would be a separate, oracle-backed change.
  2. Segment misses in static game code interpret loudly until the title is regenerated. They are counted, recorded in the TCP-visible ring and taken down the existing clean-text-miss path (§5.5). There is no fail-fast mode.
  3. Extra segments are requested through segment-qualified seeds (§5.4). There is no game.toml table.
  4. The overlay cache uses per-segment subdirectories (seg-kuseg/, seg-kseg1/) under the existing cache tag (§5.7). The filename grammar does not change.