Integrate MMX6 adaptive renderer with current framework master

Preserve current segmented dispatch, active-plan mod hooks and software scale limits. Extend function filters through the active plan, retain the last-store pointer with overlay ABI 25, and keep default config fingerprints stable. Refresh BIOS emitter stamps after byte-identical regeneration and reconcile regression fixtures.
This commit is contained in:
Matthew Stanley
2026-09-30 20:23:16 -07:00
63 changed files with 6678 additions and 145 deletions
+102
View File
@@ -0,0 +1,102 @@
# OpenGL performance campaign
Tracking: central Beads `beads-jod2`, beneath `beads-eio.3` (psxrecomp framework).
Branch: `perf/opengl-burndown-20260904`.
Starting revision: `bf61b1054313e1c80d5c2bf1b14a1e50f2e54b8a`.
This is the audited integration revision, not a claim about a shipped release.
## Objective and constraints
Improve sustained guest speed, frame consistency, and resource use at identical
settings and correctness. OpenGL is the primary renderer. Vulkan completeness
is a separate qualification exercise, never a substitute for an OpenGL fix.
Use GPT-5.5 agents for bounded implementation and independent adversarial review
of their code, measurements, and acceptance claims.
The primary route is Ape Escape, Crabby Beach, approaching and facing the water.
Inspect existing `ApeEscapeRecomp/saves/scph1001` states before choosing one;
preserve originals and record BIOS, state hash, scene, and input sequence.
Add quiet gameplay, facing away, and a transition, plus Tomba 2 and Mega Man X6
regression routes. Compare guest speed, game-produced frames, and host presents
separately to avoid treating original-game slowdown as host overload.
Test unrestricted, 50%, and 25% host CPU budgets, with affinity recorded as a
separate variable. Never change guest clocks to simulate hardware. CPU throttling
does not emulate older caches, memory bandwidth, or integrated graphics. Real
acceptance targets are an older 2-core/4-thread integrated-graphics PC and a
Deck-class handheld, with desktop and supported-platform regression coverage.
## Work packages
This table defines the approved work, not its live issue status. Beads owns
status and dependencies; experiment results belong in the evidence ledger.
| ID | Work and candidate experiments | Required evidence |
| --- | --- | --- |
| P0-1 | Repeatable route runner, artifact/config identity, machine-readable A/B reports | Same guest work, reproducible baseline, measured noise |
| P0-2 | Release flags, effective settings, overlay inventory, cache rejection and compiler failures | Clean-install Windows/Linux behavior without development caches |
| P1-1 | Unused audio/GPU histories, memory observers, tracing/logging, diagnostic allocations | Measured saved work; retain functional observers and cheap health counters |
| P1-2 | GL batch breaks, texture/CLUT dependencies, packing, readbacks, uploads, buffer streaming | Identical order, masks, blending, texture feedback, VRAM and enhanced output |
| P1-3 | Swap blocking, pacer ownership, spinning, refresh/focus, interpolation and audio delivery | No speed drift, latency regression or new underruns |
| P1-4 | Overlay discovery, validation, loading, fallback, compilation contention and cache growth | Correct identity across replacement, self-modification, restore and cold installs |
| P2-1 | Generated code, dispatch, continuations, state traffic, interpreter, lookup/linking | Exceptions, delay slots, resumes and invalidation across all execution tiers |
| P2-2 | RAM/MMIO, aliases, observers, cycle publication, deadlines and idle detection | Exact cycle charges, side effects and event ordering |
| P2-3 | GTE/PGXP state copies, inactive hooks and shadow validation | Exact GTE state and unchanged enabled enhancements |
| P2-4 | SPU/resampling, CD/XA, MDEC, DMA, disc/decompression/card I/O; SIMD/batching/prefetch | Audio, IRQs, backpressure, decode output and completion timing |
| P2-5 | Allocation, bandwidth, locality, texture caches, mods/UI, rewind, netplay | Bounded memory, enabled-feature parity and inexpensive inactive paths |
| P2-6 | PGO/LTO, compiler options, code layout, portable SIMD and build costs | Multi-title gains, arithmetic parity and portable release artifacts |
| P3-1 | Threads, helper processes and GPU compute for attributed hotspots | End-to-end constrained-host gains including all transfer/sync costs |
| P3-2 | Software renderer hotspots and separate Vulkan qualification | Explicit coverage gaps; OpenGL acceptance remains mandatory |
| P4-1 | Release integration and recurring performance gates | Clean-install route, correctness and resource checks on shipped artifacts |
Complete broad attribution before concentrating implementation on the largest
cost. Every area receives a measured disposition, including already optimized,
rejected, or deferred with a concrete missing prerequisite. Do not infer impact
from source size, suggestive names, or another console's bottlenecks.
## Measurement and acceptance
Follow [HOST_OPTIMIZATION_CONTRACT.md](HOST_OPTIMIZATION_CONTRACT.md).
Use at least three interleaved A/B pairs with all raw results, minima, medians,
discard reasons, a preregistered primary metric, and the 5% retention gate for
added complexity. Use forced faithful/candidate selectors where practical.
Separate warm-cache throughput (fixed guest work; no compiler activity) from
first-use and transition campaigns where compilation/loading is the subject.
The latter must be explicitly labeled and must not be represented as satisfying
the quiet-host warm-cache gate. Use 120-second paced windows for stutter.
Record source and artifact hashes, generated code, BIOS, toolchain, effective
settings, overlay contents, power state, pacing owner and instrumentation.
Attribute CPU samples and thread waits, GL scene/present time, readback/upload
bytes, execution tiers, device work, audio underruns, memory, allocations, page
faults and I/O. Expensive profiling is for attribution; final timing must also
run without it. Missing or reset counters must never become zero-cost evidence.
Compare deterministic guest state at intermediate checkpoints, including cycles,
deadlines, pending exceptions, device state, RAM/VRAM/SPU RAM and overlay identity.
Add rendered-output/audio comparisons and candidate hit witnesses. Exercise
invalidation, aliases, exceptions, DMA, save/load, rewind and netplay as affected.
Do not claim parity from a fallback that silently avoids the candidate.
For offload, specify ownership, immutable inputs, publication deadlines, bounded
queues, backpressure, cancellation and restore before coding. Compare inline
against one worker first. No races on guest memory, altered device ordering, or
implicit cross-thread GL context use. Count IPC, copying, driver contention,
memory and synchronization in the gain, especially on low-core-count/iGPU hosts.
## Initial evidence and completion
Source inspection found always-on audio PCM history and GP0 history, potential
GL full-VRAM readback, and multiple VRAM copies. These are hypotheses, not
measured causes of Crabby Beach slowdown. Widescreen overhang detection is
functional; its census already has a gate. Vulkan already has a staging cache.
Cycle batching, event deadlines, sparse probes, MDEC SIMD and GPU-direct GL
presentation already exist and must not be proposed as new work without audit.
Append accepted, rejected and incomplete experiments to an evidence ledger with
raw-data links. Keep issue status in central Beads and record landed commits.
Do not close the campaign until every work package has a disposition and accepted
improvements reach validated title releases. Target sustained real-time execution
at preserved settings on the selected hardware, without new correctness, audio,
or pacing failures. Record hardware limitations rather than hiding them.
+26
View File
@@ -1,5 +1,31 @@
# Widescreen support (`feat/widescreen`)
## Opt-in native-wide view anchoring (2026-09-12)
`gpu_ws_set_view_anchor(camera, min, max, active)` reads a game's signed
16-bit camera and clamp bounds at BG renderer setup. It shifts only the host
wide mirror, preserving the guest camera and canonical VRAM. The native
view's left edge appears at `extra + shift`; `extra` remains the symmetric
width budget. The geometry helper distributes reveal toward the room interior
and centers rooms narrower than the frame with explicit padding.
SW, OpenGL and Vulkan implement `gr_wide_set_view`. Explicit HUD packets keep
their existing physical origin. OpenGL's canonical-center copy is disabled
while anchoring is active because world and UI no longer share an origin.
For the existing Capcom BG2D layout, bracket each layer's packet producer with
`gpu_ws_bg2d_begin_view_layer` / `gpu_ws_bg2d_end_view_layer`. The independent
layer mask permits parallax backdrops to anchor to their own map bounds;
tile generation and ring refill use that layer's actual left/right coverage.
Actor activation uses a constant envelope covering either edge origin.
Zero configuration is inactive; 4:3 retains the original path.
Tests: `ws_view_anchor_test`, `ws_view_anchor_gpu_test`, and the hidden real
OpenGL `test_gl_readback_region.c` fixture. MMX6 is the first consumer;
owner gameplay validation is still pending (`beads-eio.3.146`).
## Earlier squash-mode implementation
Status as of 2026-06-13. Branch `feat/widescreen` in **both** `psxrecomp`
(framework) and `TombaRecomp` (game opt-in + config), pushed to remote.
Experimental. Latest: the far-backdrop edge void is largely fixed (2D backdrop
+10 -2
View File
@@ -345,7 +345,7 @@ clip_edge_width = 320 # 1..1024; default screen_w_imms[0]
- `bgez_sites` and `clip_edge_x_load_sites` (with the width) contribute to the
overlay-cache identity only when non-empty.
Explicit `bias_sites` / `range_sites` may opt into an additional resident
Explicit `bias_sites` / `bias_lower_sites` / `range_sites` may opt into an additional resident
object lead without widening terrain or render queues:
```toml
@@ -357,13 +357,21 @@ range_sites = ["0x80069BB0"]
```
`activation_guard_pixels` is added only to the live margin emitted at those
two explicit site families, and only while widescreen reveals extra world.
three explicit site families, and only while widescreen reveals extra world.
At true 4:3 it is exactly zero. `guard_pixels` remains the shared
render/terrain participation guard; keep it small when terrain producers or
model queues have fixed capacity. Both values are restricted to `[0, 256]`
and contribute to native-overlay cache identity. Changing the activation
guard requires regenerating the game and overlay code.
`bias_lower_sites` is the lower-endpoint counterpart to `bias_sites`:
an `ADDI`/`ADDIU` camera-relative bound subtracts the activation margin from
its original immediate. Both native code and the dirty-RAM path apply it.
For strip-based enemy spawning, expand the outer strip edge, initial and
vertical scan X bounds, and any associated respawn-reset interval together.
Keep authored placement flags and vertical bounds intact. Empty is inert;
configured sites require regeneration.
## Runtime block
Consumed by the cmake macro `psxrecomp_v4_add_runtime_target` (eventually)
+9
View File
@@ -213,6 +213,15 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-10-01 — MMX6 adaptive framework integration.** The opt-in function
filters from the MMX6 branch are being reconciled with the active-plan
function-entry registry and master overlay store-PC forwarding. A handled
filter publishes return registers and `pc=$ra`; snapshot requests defer until
its host callback returns. The combined callback ABI is v25 so stale v24
overlays cannot mix the two incompatible callback layouts. The adaptive
view, host tile packets, retained texture banks and screen masks remain
presentation-only enhancements; faithful guest timing is unchanged.
- **2026-09-30 — Original-disc overlay discovery (beads-eio.3.215).**
Ape Escape Primordial Ooze exposed return-adjacent functions omitted by the
archive scanner: a scheduled LUI/load before stack setup and an RA save beyond
+630
View File
@@ -0,0 +1,630 @@
# Performance experiment ledger
Campaign: [OpenGL performance campaign](../PERFORMANCE_CAMPAIGN.md).
Live work status: central Beads `beads-jod2`.
Record unsuccessful, incomplete and rejected experiments as well as accepted
ones. Local game-derived artifacts are not release fixtures. Hashes below make
the retained local evidence identifiable without distributing a savestate/ROM.
## CRAB-DISCOVERY-001: verify a usable starting scene
Date: 2026-09-04 (session date). Verdict: exploratory evidence only; no candidate
optimization, A/B comparison, production claim or correctness qualification.
- Host: Windows, Ryzen 7 9800X3D, 8 cores / 16 logical processors. Graphics
adapters reported by the OS: RTX 3080 Ti, AMD integrated graphics, Meta virtual
monitor. Actual GL device and power state were not recorded in this capture.
- Historical binary source: `_wt-ape-crabperf-20260901/build-dbg`, CMake Release
with `PSX_DEBUG_TOOLS=ON`. Existing report identifies build
`v0.3.2-alpha-139-g1bf70960-dirty`. Current source HEAD is not proof of binary
provenance. This executable was not built from the campaign branch.
- Executable SHA256:
`3b27bda4417522eb1ab331e4d2b194828f1d38fb9bbb165d1a78eb2156142ba1`.
- State: `saves-dbg/scph1001/state_800A3660_slot01.pst`, version 5, BIOS wordsum
`F67ECB99`, entry `800A3660`, codegen hash `A4319B6F`, ABI 21, codegen version 10.
SHA256 `2dfc142e65eb32e179b67d352f510d15f6d97efd22b3b43a9eef24301f3ad5a9`.
- Executable, saves, assets, BIOS assets and mods were copied into the campaign
worktree's `.local/crabby-baseline`. Writable cards/settings use that copy.
CLI selected OpenGL, no launcher, debug port 4680. Settings selected 2x SSAA
and antialiasing. `PSX_RUNTIME_PERF_DIAG=1`; no host CPU cap was applied.
- Restore receipt: `savestate_status` generation advanced from 0 to 1 with
`pending=0,last_ok=1,last_op=load,last_slot=1`. Presented screenshot visibly
confirms the sandcastle area on the beach facing the water.
- Original `ApeEscapeRecomp/saves/scph1001` slots 00/01 were v3 and slots
06/07/08/09/11 were v2; they cannot be used as v5 checkpoints.
The passive 10-second sample is retained locally at
`.local/crabby-baseline/stall-10s.json`, SHA256
`448d6a77b08479e0cca9bf11ffa21af1b3c2408ba70b9a3f1a0a7696f4a10b28`.
Snapshot start timestamps differ by 10.038749 seconds; commands are sequential,
so these are not atomic snapshots of a fixed amount of guest work.
| Observation | Value | Interpretation limit |
| --- | --- | --- |
| Overlay native dispatch delta | 0 | Does not include statically compiled game execution |
| Overlay interpreter fallback delta | 33,702 | Dispatch events, not CPU-time share |
| Interpreted instruction delta | 425,410 | Some kernel fallback is expected |
| Interpreter block delta | 33,714 | No claim that reducing calls changes the primary bottleneck |
| Compilation runs/configured | 0 / 0 | Cache/compilation provenance differs from a prepared release campaign |
| Rolling phase samples: interpreter / static / GPU | 1.59% / 80.11% / 18.30% | Instrumented phase stamps, not an independent host CPU profiler |
This observation rejects the inference that zero overlay-native dispatches
alone prove interpretation dominates the slowdown. It does not reject overlay
optimization as a candidate. No controlled compiler/process/power-state audit
was performed during this exploratory sample.
Separately queried `frame_perf` reported approximately 16.68 ms total and
16.65 ms `emu_cpu_ms_avg`. The latter is calculated from total wall time minus
present wall time, not OS thread CPU time. GPU queries span command-stream
intervals; do not interpret these fields as independent exclusive CPU/GPU busy
time or add their values together. Run without timer-query profiling and obtain
host-thread CPU samples before choosing an optimization from these values.
## HOST-BUDGET-001: verify the throttle denominator
Date: 2026-09-04 (session date). Verdict: launcher calibration, not an accepted
runtime optimization or a substitute for low-end hardware.
On the same 16-logical-CPU host, a single Python process busy-looped until
`time.process_time()` advanced by 2.5 seconds. Every run used affinity mask
`0x1`. The game was stopped. The sequence below was a calibration sequence,
not an interleaved optimization campaign. Reports live in
`.local/host-calibration/{unrestricted,core50,core25}.json`.
| Requested budget | Applied Job CpuRate (out of 10,000) | Wall seconds | Process CPU seconds |
| --- | --- | --- | --- |
| Unrestricted | none | 2.580994 | 2.500000 |
| 50% of one logical CPU | 313 | 4.679147 | 2.531250 |
| 25% of one logical CPU | 157 | 9.515031 | 2.500000 |
`--cpu-percent` is a percentage of the whole host's CPU scheduling budget.
`--core-percent` normalizes to one logical CPU equivalent independently of
affinity; it limits the whole launched job, including helper threads/processes.
This does not selectively slow the emulation thread, lower CPU frequency, or
simulate an older GPU/cache/memory subsystem. Reports retain requested and
applied rates, including integer rounding.
An earlier 0.5-CPU-second witness did not demonstrate effective throttling even
at a 3% host budget. Longer witnesses did, so sub-second smoke timing is not a
valid cap calibration. Repeat calibration on each host and use sustained game
windows rather than assuming the scheduler's short-term behavior is linear.
## ROUTE-SMOKE-001: live runner protocol validation
Date: 2026-09-04 (session date). Verdict: successful diagnostic smoke only.
No optimization candidate, target-provenance manifest, or acceptance comparison.
Used the isolated executable/state from CRAB-DISCOVERY-001, OpenGL, no host cap,
`PSX_RUNTIME_PERF_DIAG=1`, and explicitly `PSX_GL_PERF=0`. A local shortened
route held neutral input for 120 ticks. The runner confirmed completed slot-1
restore (generation 1, `last_ok=1`) and route completion (`active=false`, one
step consumed, `remaining=0`). Raw evidence:
`.local/crabby-baseline/runner-smoke.json`.
- Route wall time: approximately 2.141 seconds, including setup/status overhead.
- Counter observation window: approximately 2.672 seconds and 161 frame ticks,
including sequential snapshots/control traffic, not exactly 120 guest ticks.
- GL frame timing correctly reported unavailable with timer queries disabled.
- No native overlay compilation was configured; cache provenance remains
unsuitable for a prepared-release acceptance baseline.
The process exited after the smoke. Original saves/cards were not modified.
The committed 7,200-tick route is a longer stationary workload manifest; this
smoke does not validate its full-duration timing or the analog approach route.
See [tool usage and remaining gates](TOOLS.md).
## MEMORY-TRACE-001: remove empty production write-trace calls
Dates: 2026-09-04 to 2026-09-05. Verdict: retain as a narrowly scoped dead-work
simplification. Local write benchmark improves; gameplay gain is unproven.
This does not complete the campaign or establish the cause of the beach slowdown.
### Change and correctness evidence
Guard the six RAM/scratchpad byte, halfword and word calls to
`debug_server_trace_write_check` with the existing `PSX_NO_DEBUG_TOOLS` switch.
That function already returns immediately in production. This also removes its
old-value argument loads. Debug builds keep the original calls. No functional
memory observer, store, cycle charge, MMIO path, runtime setting, ABI or state
layout changes. Separate `-O2 -fno-lto` object inspection found the baseline's
undefined trace symbol and confirmed its absence from the candidate object.
`runtime/tests/test_memory_debug_trace_guard.py` compiles actual `memory.c`
against real runtime headers, with external device/observer stubs. It checks
all six writes through four RAM mirrors and scratchpad aliases, expected bytes
and adjacent sentinels, observer/store counts, and production/debug trace-call
witnesses. Optional `--baseline-memory` also checks the old production source.
The compact state hash covers touched bytes and selected counters, not complete
guest/device state. Structural checks preserve functional RAM observer order.
An earlier synthetic store model and then a fake-header fixture were rejected
during review; neither is used by the committed test.
### Build provenance
Framework baseline is `8ec6498d323a7abd04a3058afcbb5e36e62a8ec3`; candidate differs
only by the six guards in `runtime/src/memory.c`. Source SHA256:
- Baseline: `693be507fa53fded3e8e276f4ac0f7293d3879f3d53a8044fa8c383b4e097b99`.
- Candidate: `4fdb391d4f74da1ef2d31ce7cd61a0e0b958abab8304da37d83a2c1bb0e85253`.
Both title executables were rebuilt, using the same copied Ape Escape generated
code and assets from `_wt-ape-crabperf-20260901`. Provenance trees/build logs are
retained under `.local/ape-build-provenance`. CMake Release uses MinGW GCC/G++
15.2.0 from `C:/msys64/mingw64/bin`, `-O3 -DNDEBUG`, debug tools OFF, static runtime ON,
SDL3, recomp UI and rewind ON, netplay OFF, and block cycles ON. Vulkan was
enabled in the build but was not the selected/tested renderer. The first build
failed with the wrong windres shim; successful builds explicitly selected
`C:/msys64/mingw64/bin/windres.exe`.
- A executable SHA256: `f0c893a5743d8dfddcde75fe275d525c99c66294c5333fdf48b797de0b21096e`.
- B executable SHA256: `6fd00dc4ed8a7e9cf66f1bdd64ebaafd376d867dd5687c126bcd56f8e03dbf12`.
- A successful build-log SHA256: `c9a042d2ccab65d248a9b91b0b74d1106fc80fe74f611ea5eccfb27f350ad252`.
- B successful build-log SHA256: `3d5326d5fa5de696cb02fd3c021a2336b92362c04fcfc3f00a560a79ec34ad8e`.
The generated BIOS warned that it was stale against the current emitter
fingerprint. It was deliberately identical in A/B to retain compatibility with
the available v5 checkpoint. No native overlay compilation/loading was
configured. These are controlled local integration artifacts, **not** clean
release builds or qualification of regenerated code and shipped overlay caches.
### Local write benchmark
Actual memory implementation, external device stubs, and a genuinely empty
trace function in a separate translation unit; GCC C11 `-O2 -fno-lto`, production
macro enabled. Each run executes 50 million loops of six writes. Both artifacts
warm up with one million loops before measured A1/B1/A2/B2/A3/B3 order. Host is
the Ryzen 7 9800X3D, High Performance power plan, affinity `0x1`, no CPU cap.
Primary metric is launch-to-completion wall seconds, lower is better.
| Pair | A seconds | B seconds |
| --- | --- | --- |
| 1 | 1.4162425 | 1.1504406 |
| 2 | 1.4181166 | 1.1467513 |
| 3 | 1.4099258 | 1.1160858 |
| Minimum | 1.4099258 | 1.1160858 |
| Median | 1.4162425 | 1.1467513 |
Median improvement: 19.03%; minimum improvement: 20.84%. All six runs produced
identical selected state/counters: `state=dcc19de5`, 300 million stores,
150 million parity calls, 150 million card checks, zero trace calls. This
workload emphasizes write-call overhead and does not predict gameplay gain.
The compiler-process monitor reported no observed compilers; thermal/frequency
telemetry was not collected.
### Crabby Beach OpenGL comparison
Same v5 state as CRAB-DISCOVERY-001, staged copies of saves/cards/settings.
OpenGL context reported NVIDIA 3.3.0 driver 610.74, 2x SSAA and antialiasing,
165 Hz display, wall-clock 59.94 Hz pacing, driver vsync OFF. Both uncapped
smokes sustained approximately 60 guest Hz with no reported audio underruns.
A screenshot outside the acceptance runs confirmed the water-facing beach
scene. Smoke timings are excluded: they are not three pairs and B's screenshot
overlapped its smoke window.
Measured A/B uses `PSX_LOAD_SLOT=1`, `PSX_RUNTIME_PERF_DIAG=1`, `PSX_GL_PERF=0`,
`PSX_BENCH_WINDOW=120:420`. Primary metric is the runtime's exact 300-frame
`BENCH wall_ms`, not launcher lifespan. Every run uses a 25%-of-one-logical-core
Job budget (raw CpuRate 157/10000) and affinity `0xFFFF`, limiting the entire
job. Every process is intentionally stopped after 35 seconds. Timed windows
complete before timeout. This scheduling cap is not low-end hardware emulation.
| Pair | A wall ms | B wall ms |
| --- | --- | --- |
| 1 | 8458.662 | 8588.874 |
| 2 | 8312.503 | 8274.390 |
| 3 | 8341.652 | 7946.127 |
| Minimum | 8312.503 | 7946.127 |
| Median | 8341.652 | 8274.390 |
Median improvement: **0.81%, within observed noise**; minimum improvement:
4.41%. No convincing scene-level gain. Each window reports 180,114 interpreted
instructions, 15,564 fallback dispatches, and zero overlay loads, invalidations,
or captures. Those matching counters are not full-state parity. Under the
severe budget both builds slow down and underflow host audio. No compilers or
monitor errors were observed; configuration/mod hashes remained unchanged.
No screenshots were taken during these six runs. No runs were discarded.
### Retained evidence and remaining gates
All following paths are relative to the worktree and are intentionally ignored:
| Artifact | SHA256 |
| --- | --- |
| `.local/memory-trace-bench/campaign.json` | `fd1944a1fc3a8ea750e37ceda9fb12367d9bb85adc8f12d01fdc28df3a3b06ad` |
| `.local/game-memory-ab/campaign.json` | `9d0c70ae9b9d221bc601e24cf7563dcdc61f84fecbae97806e7a7d068d8b5e1d` |
| `.local/run_memory_bench.py` | `90f6fec928f33f3d5e6395d31ce25d7f86ba16359d79851dd8903ef9ef48057d` |
| `.local/run_game_ab.py` | `e3e9fd310600c3dbde14d4b2b4fff741cee2a063c14bbfc9ffe7f8416f71bff9` |
JSON retains per-run evidence and artifact/configuration hashes; game/state
assets are not distributed. The focused compiled regression passes, as do the
FMV quiet and dirty-text admission/continuation guard tests. The combined
campaign/host-launcher/stall-report suite passes all 63 tests, and CMake's
registration guard finds all 120 runtime/recompiler test files registered.
These are not a full runtime CTest pass or cross-platform validation. Two unrelated
structural tests also fail on the immutable baseline; details are in
[the audit](RUNTIME_AUDIT.md#validation-gaps).
Retain the change only as removal of already-empty production work, under the
contract's simplification allowance, not as a 5% gameplay optimization. Full
checkpoint parity, multi-title/low-end hardware, moving/transition routes,
long-window frame consistency, clean-install and cross-platform release gates
remain open. Broader GPU/audio history and GL costs remain separate candidates;
the flat gameplay result does not justify focusing the campaign on memory.
## PACER-COST-001: attribute idle pacing CPU cost
Date: 2026-09-05. Verdict: baseline attribution only, no pacing change or
gameplay speed claim. Compile actual `frame_pacing.c` and `host_time.c` against
SDL3 with GCC 15.2.0 `-O2 -DPSX_SDL3`. The driver calls `frame_pacer_wait` 600
times at 59.94 Hz with no guest work, using `GetThreadTimes` for calling-thread
CPU time and SDL's performance counter for wall time. The first call anchors
without waiting, so 599 periods take approximately 9.9933 seconds.
| Run | Wall seconds | Thread CPU seconds |
| --- | --- | --- |
| 1 | 9.993298 | 0.812500 |
| 2 | 9.993297 | 0.859375 |
| 3 | 9.993297 | 0.765625 |
This is approximately 7.7-8.6% of one logical CPU spent on idle pacing on the
Ryzen 7 9800X3D, High Performance plan, unrestricted scheduling and inherited
affinity. The existing sleep decision stops sleeping below two milliseconds,
then spins until the deadline. This local cost makes the spin tail worth
measuring in gameplay; it does not establish an actual pacer bug or explain
exclusive wall-time phase attribution under a Job cap. No candidate was tested.
Agents held compilation during these runs; no continuous compiler-process or
thermal monitor was attached to this exploratory probe.
Local retained driver `.local/pacer-cost.c`, SHA256
`d98305437fb4e8c86769f31893e43bf63ade8568d761f522e0842817a92ac105`;
executable `.local/pacer-cost.exe`, SHA256
`7aa80698ca5d3a831d0d651601b3c5b2e8bc3665e2eec82f5e426c308eae7ddb`;
raw output `.local/pacer-cost-results.txt`, SHA256
`749fc5185fa512e3a00390f0022fa8939993763ca3b9f6e123ba76ff6654fea1`.
## HISTORY-002: isolated GPU and audio history candidates
Date: 2026-09-05. Correctness milestone; controlled resource/time comparison
pending. No gameplay gain accepted from the correctness runs below.
### Candidate boundaries
- `GP0-HISTORY-001`: compile the GP0 command-history allocation, recording and
diagnostic guest-stack scan only when debug tools are enabled. Production
history accessors remain linkable but report zero capacity/count and empty
dumps. Keep source tracking, opcode counters, WS census, functional overhang
detection, and all command execution unchanged. Removes a 104 MiB allocation
request; debug builds retain always-on history.
- `AUDIO-PCM-001`: compile out four PCM history arrays and their writes in
`PSX_NO_DEBUG_TOOLS` builds. Preserve every tap's head, nonzero/audible/peak
statistics, rates, event records and pump/underrun/mute counters. Production
WAV dump returns the existing error result without opening a file; debug
WAV history is unchanged. Actual GCC object BSS is 83,886,272 bytes with debug
history and 16,777,408 without it: exactly 64 MiB less backing capacity.
Both use the existing build switch, not a new runtime selector or lazy allocator.
Capacity is not equivalent to resident memory or a measured speed improvement.
Packed production audio tap counters may share cache lines between callback and
emulation threads; require the real bridge-audio path in performance checks.
### Focused checks
The GPU test compiles actual `gpu.c` and real headers with external renderer
stubs. It compares exact serialized GPU snapshots and the entire VRAM at eleven
checkpoints across environment, fill, upload, copy, polyline and NOP commands.
It also checks debug history count, copy source/stack pointer and polyline
header, and production unavailability. This tests parser/state behavior, not
the rendering correctness of a stubbed backend. Known pre-existing polyline
source attribution was not changed or silently redefined by the test.
The audio test compiles actual `audio_trace.c` in both modes. All four taps'
observed statistics/events compare equally, including a new event after ring
wrap, signed extremes, audible thresholds, rates and resets. Debug WAV headers
and sample bytes are checked for all taps and for wrap/clamped eviction;
production must not create the requested WAV. Review rejected the first test's
hard-coded output comparison and replaced it with real observations.
Both focused tests passed root execution. The 63 campaign/host/stall-report
tests passed, as did the memory-trace regression. CMake's registration guard
finds 122 test files registered. This is not a full cross-platform CTest pass.
### Isolated build provenance
B is the previous memory-guard baseline, C adds only the GP0 gate, and D adds
only the audio gate. All use identical local-only endpoint memory/restore/
renderer/audio witnesses and an optional checkpoint probe. Original B from
MEMORY-TRACE-001 is preserved. Source trees, exact options/commands/logs and
hashes are in `.local/ape-build-provenance/builds-bcd-manifest.json`, SHA256
`176ed3d54939fab3498ced5d1e3dd38566a9ef76e7e4cb23f5514a8d9a740530`.
| Variant | Executable SHA256 |
| --- | --- |
| B metrics | `f1310b19eb3633d81e01717f36ab60f4d6cd4d53aa3838adf0e614d10bbf4914` |
| C GP0 | `74063d9dadef152d023c0dead18eabde854773c4399f7f52f49713fb67315045` |
| D PCM | `e9f525296dfdb0be2a50596a1198bb9735be0371d21afa627cc6e08dfe05bd39` |
Release GCC 15.2.0, SDL3, static runtime, debug tools OFF, rewind ON, netplay OFF,
same donor generated game/BIOS and saved scene as MEMORY-TRACE-001. The stale
generated-BIOS warning persists. A default all-target build failed in an
unrelated donor title test on a long Windows dependency-file path; explicit
`psx-runtime` builds succeeded. No release qualification is claimed.
### Real OpenGL checkpoint comparison
Separate uncapped 20-second processes restore the same slot-1 v5 beach state.
All variants report active OpenGL (`gl_active=1`, `gr_backend=1`), completed
restore (`generation=1,pending=0,last_ok=1,last_op=load,last_slot=1`), and active
non-legacy bridge output with advancing host-tap frames. The local-only probe
writes raw boot-state blobs at frames 120, 121 and 420, plus CPU timing sidecars.
For **both C and D versus B, every blob and sidecar is byte-identical**.
| Frame | Raw guest snapshot SHA256 (all B/C/D) |
| --- | --- |
| 120 | `505f7d94c5c7e518e099669ed1623296d9210abc30eab4bc907956e91ff652c6` |
| 121 | `1e0b19c3a913733aba9bb403b1e01a1c9d309cc919e3accd9c52f3d341d3429b` |
| 420 | `55ebbe6a36ac2674952a4672575dfb619547ffec0a059f9a277bb955b9d074e2` |
Each blob is 3,685,569 bytes. The existing save format covers CPU registers,
RAM, scratchpad, VRAM, SPU RAM and serialized device/IRQ/clock/cache/dirty state.
The 411-byte sidecars add multiply/divide and GTE completion timestamps, all 33
read-absorb bytes and load-timing scalars. CPU function pointers, host audio
queues, SDL/pacer/presentation histories and diagnostics are excluded. This is
one scene's checkpoint evidence, not exhaustive state/restore or multi-title
qualification. Full campaign correctness gates remain open.
Evidence: `.local/history-correctness-002/report.json`, SHA256
`7feb56b0f0119688e460ff46b027d3aaf9549021e996a935830e26a6154b21f2`;
driver `.local/run_history_checks.py`, SHA256
`f6f29b505225fc0ff8688d9a86921be60590a81288980c5fd7d4c8b56961d0f4`.
Raw game-derived snapshots remain local and uncommitted.
The first attempt, `.local/history-correctness-001`, failed closed because the
staged checkpoint hook was absent despite an earlier build report. Explicit
source and binary-string checks plus forced rebuilds corrected the artifact.
That failed attempt is retained, not counted as successful state comparison.
Checkpoint saves can publish deferred cycles and force GL readback. These runs
also overlapped unrelated builds, so **none of their timing or memory values is
acceptance evidence**. The timed runner explicitly unsets checkpoint capture.
The preregistered primary metric is exact-frame-420 process PrivateUsage; the
secondary metric is frames-120:420 wall time, with three interleaved pairs for
each independent candidate and a validated 25%-one-core Job cap. It requires
stable input/artifact hashes, active OpenGL/audio and completed restore, and
aborts on compiler contamination or missing/nonfinite metrics.
### Timing gate outcome and disposition
The benchmark agent monitored the host for ten minutes without finding 30
consecutive seconds free of compiler/benchmark activity. Concurrent SNES builds
included Mega Man X and Super Mario Kart. No timed candidate process was
launched and `.local/game-gp0-memory-ab` was not created. Unrelated work was
not stopped. The power plan remained High performance before and after;
thermal state was not measured.
The prepared twelve-run driver is `.local/run_game_gpu_memory_ab.py`, SHA256
`ce01b28c627dd5a8d0abe3217a49e2fd1d576fc7128153de502487f69a96f64b`.
It includes `cc.exe` in compiler-contamination detection. Resume the paired
campaign in a quiet host window before making retention or speedup claims.
Disposition: committed candidate implementation in the isolated performance
worktree, **pending performance acceptance**, not release qualification. The
104 MiB GP0 allocation request and 64 MiB PCM static-storage capacity removals
are established; resident-memory savings and gameplay speedups are not.
Focused parity tests and real OpenGL checkpoints passed, but the broader
correctness matrix and the quiet-host comparisons remain open.
## UNCAP-HISTORY-003: uncapped OpenGL throughput
Date: 2026-09-05. User-requested follow-up to HISTORY-002. Uncapped frame
throughput removes a limiter that can hide spare capacity; it does not isolate
the process from concurrent host work or simulate lower-end hardware.
### Mechanism and provenance
`PSX_VSYNC=0` alone leaves the wall-clock pacer active. Debug/manual/load turbo
can skip presentation, and Ape's historical `ape.framerate.uncapped` plugin
actually selects frame interpolation. None is appropriate here. The native
VBlank-rate API already permits zero frontend pacing without changing guest
cycle accounting (`mod_plugins.h`, `psx_mod_set_native_vblank_rate`).
Identical local-only staged `main.cpp` hooks set `g_frame_period_ms=0` and
`g_video_vsync=0` after host-refresh selection and before GL context creation
when `PSX_PERF_UNCAPPED=1`. These are the same host-pacing effects as the native
API; the hook does not call the API or change guest clocks. It refuses active
interpolation. A separate endpoint `[BENCH_PACING]` reports actual period,
effective swap interval, wall-pacing decision, interpolation and load-turbo
state. Normal presentation and the audio bridge remain enabled.
The three incremental Release `psx-runtime` builds succeeded. Original paced
executables and the previous manifest remain unchanged; staged original
`main.cpp` files are backed up. Tracked runtime sources were not changed for
this experiment. New manifest:
`.local/ape-build-provenance/uncapped-builds-manifest.json`, SHA256
`184dfa8a1d0305fc9f1d5aafdd6dda9cec4d0d24b8a73bf684775eefa7103ca4`.
| Variant | Uncapped executable SHA256 |
| --- | --- |
| B baseline | `8050206bda873fd42a8cc790c0b9b556680cdcb966893a1414d3774bbbf11dd6` |
| C GP0 gate | `a43233c44f9c6f6f1a8b7115bc77260de0292571c556e44a5afd97bb4b00d042` |
| D PCM gate | `566297db442e5e062b2b90864302a20d4cbdbe769496d6cdf7d2560089aff5f3` |
### Uncapped correctness smoke
Root ran separate 12-second uncapped processes with checkpoint capture enabled.
All B/C/D raw guest snapshots and CPU sidecars at frames 120, 121 and 420 are
byte-identical to one another **and to the paced B checkpoints in HISTORY-002**.
All restore, OpenGL and bridge-audio witnesses passed. Endpoint pacing reports
zero period, swap interval, wall pacing, interpolation and load turbo. These
checkpoint runs are not timing or memory acceptance measurements.
Evidence: `.local/uncapped-correctness-001/report.json`, SHA256
`551732b3411c74a113d14edf46f185d364684863ec199d223f3b444ee9474709`;
driver `.local/check_uncapped_history.py`, SHA256
`8acb9e3894286237cfbba6b3f3e468c5ff3698c9762c2de488164fa151bd3e91`.
The existing save-format coverage/exclusions from HISTORY-002 still apply.
### Timed protocol
Three interleaved B/C and B/D pairs, reversing within-pair order for pair two:
`B,C,B,D; C,B,D,B; B,C,B,D`. Each process restores the same beach save and runs
with unrestricted requested host CPU budget, affinity `0xFFFF`, the same
OpenGL 2x SSAA/AA settings and diagnostic counters, and no checkpoint capture.
Primary metrics are wall time for guest frames 120:420 and guest VBlanks/s
(`300000 / wall_ms`), not unique rendered game frames or physical display FPS.
PrivateUsage at frame 420 is secondary. Twelve-second process timeouts provide
bounded cleanup; whole-process CPU totals are not the fixed-window CPU cost.
The driver requires unchanged artifacts/settings/game/state/mod inventories,
completed restore, active OpenGL/audio, effective uncapped pacing and matching
guest-work counters. Compiler observations are retained instead of waiting for
a quiet host; monitor failures invalidate results. No result is automatically
accepted based only on the absence of observed compilers. Eight synthetic
driver checks passed root execution.
Local driver `.local/run_uncapped_history_ab.py`, SHA256
`ab3fd3efd56b3088a11221d907a5202101dbfff21fb19186b647cef97bf7c8d3`;
tests `.local/test_run_uncapped_history_ab.py`, SHA256
`3a4b19138e1272490534b3bea84099e32a856d9d922c368fa9f85329abf23044`.
Uncapped audio production outruns the physical playback timeline. An active
audio device and advancing host tap do not establish identical callback counts,
absence of overflow or normal-speed listening quality. Neither title-wide
correctness nor low-end realtime pacing/audio acceptance follows from this
throughput experiment.
### Short-window results
All twelve runs passed the fixed-window, effective-pacing, restore, backend,
audio and inventory checks. No monitored compiler processes or monitor errors
were observed. High performance power plan before/after; no thermal telemetry
or continuous monitoring of all other host workloads. Classification remains
`exploratory_no_compiler_observed`, not automatic acceptance.
| Comparison | Baseline wall ms (three runs) | Candidate wall ms (three runs) | Median VBlanks/s B -> candidate | Median throughput change |
| --- | --- | --- | --- | --- |
| GP0 gate | 1205.440, 1247.664, 1181.284 | 1178.411, 1181.626, 1177.182 | 248.872 -> 254.580 | +2.29% |
| PCM gate | 1212.670, 1185.505, 1215.057 | 1165.711, 1208.837, 1179.628 | 247.388 -> 254.317 | +2.80% |
Within-pair throughput changes were +2.29%, +5.59%, +0.35% for GP0 and +4.03%,
-1.93%, +3.00% for PCM. This spread, especially the PCM sign reversal, prevents
treating the modest median gains as robust. Fastest-run throughput changes were
only +0.35% and +1.70%, respectively. No additive combined gain is established:
each candidate was tested independently.
Endpoint PrivateUsage fell in every pair: GP0 by 102.87, 103.63 and 105.49 MiB;
PCM by 62.00, 63.31 and 64.07 MiB. This measures process private commit in this
scene, not resident physical memory. The amount includes allocator/driver
variation and is not an exact measurement of the removed arrays alone.
Raw report `.local/game-uncapped-history-ab/campaign.json`, SHA256
`5e6477c3969eb8799aee300ffa153903d39c48010a814be76f2cfc19413afb4d`;
power witness `.local/game-uncapped-history-ab/power_plan.json`, SHA256
`034c141dd4dd57cdbd10819bf884aa01c628e1fb496dc1bee83979aa52a55da4`.
All stdout/stderr and host reports are retained beside the report. Root
recomputed paired throughput and memory deltas from the raw rows.
Because the measured windows last only about 1.2 seconds, a separate replication
uses frames 120:1920 (1,800 guest VBlanks), identical executables/settings and
the same twelve-run order. This extends the workload past the GP0 history ring's
wrap point without changing game speed settings or rendering quality. It uses
a separately named driver/output directory and 15-second process timeouts;
the short-window artifacts are preserved.
### Longer-window replication
All twelve runs passed the same evidence checks at frames 120:1920. B/D GP0
history totals reached 1,180,535 commands, beyond the 1,048,576-entry capacity;
C remained zero/zero. Work counters matched within each candidate comparison.
This is a workload/wrap witness, not a new byte-level checkpoint at frame 1920.
| Comparison | Baseline wall ms (three runs) | Candidate wall ms (three runs) | Median VBlanks/s B -> candidate | Median throughput change |
| --- | --- | --- | --- | --- |
| GP0 gate | 7102.496, 7222.937, 7202.571 | 7108.128, 7020.108, 6990.751 | 249.911 -> 256.406 | +2.60% |
| PCM gate | 7295.579, 7127.430, 7366.212 | 6982.187, 7070.005, 6977.142 | 246.725 -> 257.799 | +4.49% |
Within-pair throughput changes: GP0 -0.08%, +2.89%, +3.03%; PCM +4.49%,
+0.81%, +5.58%. Fastest-run throughput changes: +1.60% and +2.15%.
PrivateUsage fell in every pair: GP0 by 105.18, 106.13 and 106.69 MiB;
PCM by 63.12, 61.83 and 63.25 MiB.
Compiler activity was observed in two runs: GP0 pair-one C (32 observations)
and PCM pair-one B (30). Observed names included `cc.exe`, `cc1.exe`,
`cmake.exe`, `collect2.exe`, `g++.exe`, `gcc.exe`, `ld.exe` and `ninja.exe`.
The monitor covers the full process lifetime, not precisely the measured
frame window. No monitor errors occurred. All runs remain in the report;
no post-hoc removal of the slower/contaminated runs was used. Classification:
`exploratory_contaminated`. High performance power plan before/after;
thermal state was not measured.
Evidence:
- `.local/run_uncapped_history_long_ab.py`, SHA256
`459c852c71fd0f4c927a331a91867566704e1fb811b4f2be38fb1a55db6a19d9`.
- `.local/game-uncapped-history-long-ab/campaign.json`, SHA256
`f71891ad99b20ab5bb1f2e148931494c90951f713d25c8ddd6d4107b436007e7`.
- `.local/game-uncapped-history-long-ab/power_plan.json`, SHA256
`7f115cc8e9fa6998f4e01f557098113881fbe2b1264f0434060fd0621a7179a0`.
Root reviewed the longer driver's delta: only window/frame-count/FPS numerator,
output path, metric labels and timeout differ from the short driver. Synthetic
checks accepted a correctly converted 1,800-frame observation and rejected the
old 300-frame observation. Root recomputed paired changes from raw results.
### Disposition
Uncapped benchmarking is useful here: it exposes approximately 250 guest
VBlanks/s of throughput instead of hiding spare capacity behind the 60 Hz
limiter. Both passes suggest modest gains, but run variation and the longer
pass's contamination prevent claiming a reliable general speedup. These are
not additive gains or evidence that Crabby Beach slowdowns are fixed.
Reduced process private commit is consistent across all twelve candidate pairs.
Together with focused parity and the three exact guest checkpoints, this
supports retaining the candidates in the isolated burndown branch for further
qualification. Normal-speed audio/pacing, constrained-host comparisons,
multi-title correctness and actual low-end hardware remain open. Do not ship
the local uncap hook as a gameplay default.
## MANUAL-INTEGRATION-004: user sign-off and master integration
Date: 2026-09-05. The user reviewed the Tomba 2 and Mega Man X6 manual builds,
reported that they "look good", and explicitly authorized committing and
merging the work to main/master. This is user acceptance of the supplied test
builds, not an instrumented audio comparison, full-title playthrough or a new
performance measurement.
Both manual builds used the combined guards from `04371d60`, normal pacing,
OpenGL, recomp-ui, production debug-tools OFF, rewind ON, existing generated
game/BIOS code and isolated copied memory cards. Recomp-ui displayed verified
discs with BIOS paths supplied. Final executable SHA256 values:
- Tomba 2: `6d978d8e2a736aa8b94cb076091117edbe76f6fa7c7f061df227233be8a648ad`.
- Mega Man X6: `4c99e7a6092122b6675daf83f52eddb76a71b0b5290b474c7f4d494df84135b2`.
The initial Tomba build disabled rewind to bypass a dependency lookup; review
rejected that workaround and the final build restored rewind. Mega Man X6's
older recomp-ui headers were incompatible with the runtime, so its manual
build used the compatible Tomba 2 recomp-ui checkout. Neither original game
installation was edited. Local build logs/settings/screenshots remain under
the original burndown worktree's `.local/manual-validation` directory.
Integration uses `origin/master` at `17f49ad3` as its base. Only the six campaign
commits `6488414a`, `d38f97e1`, `8ec6498d`, `28e9c844`, `0cf93d57`, and
`04371d60` were transplanted; the older review-branch ancestry was excluded.
All six applied without conflicts. The runtime delta remains only the three
production diagnostic guards; benchmark uncapping and local game artifacts
are not part of the merge.
The first integration GPU test failed because its old polyline fixture supplied
only one vertex before the terminator. Upstream now correctly requires two
vertices. The fixture now supplies both; the upstream runtime fix is unchanged.
On the integrated sources, all three actual-source guard regressions and the
63 campaign/host/stall-report tests pass. The previously tested game executables
are not rebuilds of this newer upstream base; no such equivalence is claimed.
The CMake registration guard also passes with 133 test files registered.
Disposition: user-approved integration of the scoped optimizations, tests and
measurement tools. The original WIP label records the historical state at
implementation time. The broader burndown, general speedup qualification and
actual low-end hardware coverage remain open after this merge.
+119
View File
@@ -0,0 +1,119 @@
# Runtime Audit
Source audit for the runtime burndown. The matrix records the initial source
disposition; follow-up implementation and qualification are noted below.
The memory-trace candidate has been implemented and measured. See
[MEMORY-TRACE-001](EXPERIMENTS.md#memory-trace-001-remove-empty-production-write-trace-calls)
for results and limitations. Paths are repository-relative.
Follow-up: [HISTORY-002](EXPERIMENTS.md#history-002-isolated-gpu-and-audio-history-candidates)
implements production-only GP0 history and audio PCM-storage gates. Focused
tests and real OpenGL guest/CPU-timing checkpoints pass.
[UNCAP-HISTORY-003](EXPERIMENTS.md#uncap-history-003-uncapped-opengl-throughput)
adds uncapped throughput and process-private-commit measurements; normal-speed
and low-end acceptance remain open. No shipped census, GL hold-copy, pacing,
threading or Vulkan behavior has changed.
[PACER-COST-001](EXPERIMENTS.md#pacer-cost-001-attribute-idle-pacing-cpu-cost)
provides idle-pacer CPU attribution, not a speedup claim.
[MANUAL-INTEGRATION-004](EXPERIMENTS.md#manual-integration-004-user-sign-off-and-master-integration)
records the user's Tomba 2/Mega Man X6 sign-off and approval to integrate the
scoped guards. This does not close the wider performance/correctness campaign.
## Disposition Matrix
| Area | Current disposition | Evidence | Candidate / next check | Correctness guardrails |
| --- | --- | --- | --- | --- |
| Host CPU budget | Tooling calibration only, not an optimization. | `docs/performance/TOOLS.md:22-24` defines `--core-percent` as one logical CPU-equivalent, separate from affinity. Sustained calibration on this 16-logical-CPU host: unrestricted 2.580994 wall / 2.500000 CPU sec, core50 raw 313 -> 4.679147 wall / 2.531250 CPU sec, core25 raw 157 -> 9.515031 wall / 2.500000 CPU sec in `docs/performance/EXPERIMENTS.md:64-91`. | Use to expose CPU-limited behavior; do not treat as low-end hardware equivalence. | Does not change guest clocks, CPU frequency, or isolate the emulation thread. |
| P1-1 audio observer | Active production observer; comment is stale. | `runtime/include/audio_trace.h:40-42` defines 4 taps. `runtime/src/audio_trace.c:17-36` stores 4 taps of `2^22` stereo frames: 64 MiB PCM, plus `EV_RING_CAP = 2^19` events at `runtime/src/audio_trace.c:20-54` (16 MiB by current struct layout). Header/source comments still describe 3 taps / 48 MiB. Hot writes are `audio_trace_pcm` / `audio_trace_event` at `runtime/src/audio_trace.c:92-126`; host-audio calls/events appear at `runtime/src/main.cpp:2071`, `runtime/src/main.cpp:3086-3166`, and `runtime/src/main.cpp:3195`. | `AUDIO-TRACE-001`: measure footprint and CPU cost, then gate or reduce trace only with an explicit diagnostic selector. | Preserve underrun/drop/mute observability and dump workflows; keep cheap health counters if full trace is gated. |
| P1-1 GPU histories | Mixed active/lazy/toggleable observers. | GP0 ring lazy-allocates at `runtime/src/gpu.c:4859-4866` and records commands at `runtime/src/gpu.c:5058`, with public entry shape at `runtime/include/gpu.h:106-129`. A0/C0 histories are bounded at `runtime/src/gpu.c:4346-4414` and `runtime/src/gpu.c:4459-4494`. GL coherence/present rings are metadata observers at `runtime/include/gpu_gl_renderer.h:137-176`. | Measure before gating. Split active default costs from already-gated diagnostics. | These support route, selfcheck, widescreen, and renderer-debug workflows. |
| P1-1 `CENSUS-001` WS census | Active by default in ordinary draw path; only debug server consumes/toggles it. | `ws_census_on = 1` at `runtime/src/gpu.c:4950`. Every drawing primitive can call `ws_census_record` from `runtime/src/gpu.c:5062-5066`; the early return is at `runtime/src/gpu.c:5006-5007`, but default-on records allocate a 48 MiB ring at `runtime/src/gpu.c:4937-4950` and read camera with two `psx_read_half` calls at `runtime/src/gpu.c:5017-5018`. The only `gpu_ws_census_*` consumer found is debug server on/off/dump/seq at `runtime/src/debug_server.c:8014-8030` and command registration at `runtime/src/debug_server.c:13540`. `psx_read_half` itself observes data-shard/load-store modes at `runtime/src/memory.c:1744-1753`, so changing census reads is not automatically behavior-neutral. | Candidate: default-off or debug-tool gate after proving no route/selfcheck/widescreen automation depends on default-on census data. | Preserve widescreen diagnosis and avoid changing lockstep/data-shard observer behavior in diagnostic modes. |
| P1-1 / P2-5 `MEMORY-TRACE-001` | Implemented debug-tool guard, not a planned attribution probe. | `debug_server_trace_write_check` is declared at `runtime/src/memory.c:955-958` and implemented at `runtime/src/debug_server.c:9449`. Six hot write sites are already guarded by `#ifndef PSX_NO_DEBUG_TOOLS`: RAM/scratchpad word at `runtime/src/memory.c:1683-1685` and `runtime/src/memory.c:1718-1725`, half at `runtime/src/memory.c:1815-1817` and `runtime/src/memory.c:1849-1853`, byte at `runtime/src/memory.c:2154-2156` and `runtime/src/memory.c:2184-2187`. | Current candidate is validating this guard/removal surface, not inventing new instrumentation. | Do not change immediate write semantics, cycle accounting, parity last-writer, dirty-RAM, text/overlay watches, card-data checks, or MMIO ordering. |
| P2-1/P2-2 CPU write-call path | Real native-game exercise exists; hotspot still needs samples. | Generated Ape Escape code calls through `cpu->write_word`, `cpu->write_half`, and `cpu->write_byte`, e.g. `.local/ape-build-provenance/ape-src/generated/SCUS_944.23_full_00.c:120`, `.local/ape-build-provenance/ape-src/generated/SCUS_944.23_full_00.c:421`, `.local/ape-build-provenance/ape-src/generated/SCUS_944.23_full_00.c:11843`. Runtime assigns these pointers to `psx_write_*` at `runtime/src/main.cpp:13843-13847`; implementations start at `runtime/src/memory.c:1585`, `runtime/src/memory.c:1791`, and `runtime/src/memory.c:2134`. | Candidate: immediate-call removal / native write-call fast path, measured separately from renderer work under host CPU budgets. | Scope is call overhead only; no observer, MMIO, load/store recording, or cycle behavior change. |
| P1-2 GL CPU uploads | Active when CPU writes VRAM. | Pending upload rects are capped at 16 at `runtime/src/gpu_gl_renderer.c:437-456`; overflow flushes at `runtime/src/gpu_gl_renderer.c:541-589`. Flush converts 1555 to RGBA, updates upload/raw textures, streams VBO data, and draws rects at `runtime/src/gpu_gl_renderer.c:1297-1378`. Upload timing diagnostics are already env-gated at `runtime/src/gpu_gl_renderer.c:459-470`. | `GL-UPLOAD-001`: count overflow, bytes, conversion time, and flush causes on replay. | Preserve VRAM feedback, masks, CLUT/page dependencies, and dual/netplay CPU-auth mode. |
| P1-2 GL pack / feedback | Active when drawn dirty VRAM is sampled. | Draws accumulate a dirty union at `runtime/src/gpu_gl_renderer.c:1504-1530`. Sampling can force pack at `runtime/src/gpu_gl_renderer.c:1452-1472`; pack mirrors dirty HR FBO data back to raw texture at `runtime/src/gpu_gl_renderer.c:1424-1450`. | `GL-PACK-001`: measure pack frequency and dirty area before considering narrower tracking. | Preserve texture feedback, CLUT sampling, PGXP/subpixel edge widening, mask/stencil, and native-wide output. |
| P1-2 GL batch splits | Deferred until batch diagnostics implicate it. | Semi-transparent textured primitives are isolated at `runtime/src/gpu_gl_renderer.c:1958-2055`; comments tie this to STP ordering at `runtime/src/gpu_gl_renderer.c:2009-2018`. Non-triangle flat geometry is immediate at `runtime/src/gpu_gl_renderer.c:1872-1939`. Batch diagnostics exist at `runtime/include/gpu_gl_renderer.h:214-219` and `runtime/src/debug_server.c:7733-7754`. | Use existing `frame_perf`/batch diagnostics to prove split cost before changes. | Draw order, semi-transparency, mask checks, and feedback are high-risk. |
| P1-2 GL readbacks/probes | Mostly already gated or non-steady-state. | Full VRAM readback is `ensure_cpu` at `runtime/src/gpu_gl_renderer.c:1479-1497`; normal 15-bit present uses GPU-direct selection at `runtime/src/main.cpp:7047-7078`. `gl_fbo_peek` / `gl_vram_diff` are debug commands at `runtime/src/debug_server.c:9036-9091`; present pixel probes require `PSX_GL_PRESENT_PROBE=1` at `runtime/src/gpu_gl_renderer.c:789-835`. | Verify route logs do not hit unexpected `ensure_cpu` or debug readbacks during measurement. | Keep diagnostic readbacks available; disabling unused tools is not a measured optimization. |
| P1-2 hold-last copy | Existing capture calls are unconditional in ordinary present paths; safe gating is not implemented. | `hold_capture_drawable` copies before swap at `runtime/src/gpu_gl_renderer.c:717-734` and is called by CPU/blank/VRAM/wide presents at `runtime/src/gpu_gl_renderer.c:3016`, `runtime/src/gpu_gl_renderer.c:3032`, `runtime/src/gpu_gl_renderer.c:4234`, and `runtime/src/gpu_gl_renderer.c:4338`. Consumers include netplay hold at `runtime/src/main.cpp:5578-5611` and rewind pause at `runtime/src/main.cpp:6174-6189`. `gl_swap_with_osd` draws OSD after capture and before swap at `runtime/src/gpu_gl_renderer.c:3961-4034`, so capture is pre-OSD. | `GL-HOLD-001`: gate capture only when all activation/transition dependencies are enumerated. | Preserve rewind pause, netplay catch-up, interpolation hold, blank/tip-hold transitions, and pre-OSD capture ordering. |
| P1-3 pacing/audio threading | No change candidate without profile attribution. | Vsync ownership and swap interval are derived at `runtime/src/main.cpp:2846-2875`; wall pacing is a deadline cap at `runtime/src/main.cpp:2878-2913`; vblank present calls body then pacer wait at `runtime/src/main.cpp:7423-7449`. | Measure wait/swap/pacer/audio underrun counters under calibrated host budgets. | No speed drift, latency regression, focus/refresh regression, or new underruns. |
| P1-4 overlays/compilation | Not attributed as runtime contention; several safeguards already avoid hot-path capture. | Overlay capture is enabled/configured from runtime config at `runtime/src/main.cpp:11929-11957`. Universal guest writes clear compact page evidence instead of serializing snapshots at `runtime/src/memory.c:786-800`; dirty interpreter admits decodable CD-DMA overlay words at `runtime/src/dirty_ram_interp.c:2848-2851`. | Separate cold-start/transition attribution from warm steady-state; do not “optimize” overlay capture without samples. | Preserve overlay identity, replacement, self-modification, restore, and cold installs. |
| P2-3 GTE/PGXP | Several CPU-side optimizations/gates already exist; residual cost needs samples. | GTE debug/projection rings are under `#ifndef PSX_NO_DEBUG_TOOLS` around `runtime/src/gte.cpp:1788-1818`. GTE register accessors read/write CPU state directly (`runtime/src/gte.cpp:2003-2032`, `runtime/src/gte.cpp:2036`, `runtime/src/gte.cpp:2110`), while timing stalls live in `runtime/src/psx_cycles.c:700-770`. PGXP hooks have a single hot gate at `runtime/src/pgxp.cpp:81-85`; hook bodies early return when inactive, e.g. `runtime/src/pgxp.cpp:419-423`. Renderer dirty tracking still depends on PGXP/subpixel at `runtime/src/gpu_gl_renderer.c:1504-1530`. | Attribute PGXP/GTE CPU cost separately from GL pack/upload before proposing changes. | Exact GTE state, command deadlines, load-delay give-back, and unchanged enhanced output. |
| P2-4 SPU/CD/MDEC/DMA/I/O | Some subsystem optimizations/gates already exist; audio trace remains concrete. | Audio evidence above. SPU already skips the 24-voice walk when enabled with no active voices and no shadow tap at `runtime/src/spu.c:999-1033`. MDEC trace is debug-gated at `runtime/src/mdec.c:122-130`; MDEC has an SSE2 row path at `runtime/src/mdec.c:610-630`. DMA writes also go through `psx_write_word` in `runtime/src/dma.c:586`, `runtime/src/dma.c:652`, `runtime/src/dma.c:763`, `runtime/src/dma.c:855`, `runtime/src/dma.c:884`, `runtime/src/dma.c:913`, and `runtime/src/dma.c:1152`. | Start with audio trace and write-call attribution; expand only if samples point to SPU/CD/MDEC/DMA. | Preserve audio, IRQs, DMA timing, decode output, backpressure, and I/O behavior. |
| P2-6 build options | Debug tools already compile out; no build-option gain claimed. | `PSX_NO_DEBUG_TOOLS` is defined when debug tools are off at `runtime/runtime.cmake:1436-1439`. GL perf queries are compiled out or can be disabled with `PSX_GL_PERF=0` at `runtime/src/gpu_gl_renderer.c:3466-3559`. | Build-flag A/B only after route attribution. | Multi-title correctness, portable artifacts, arithmetic parity. |
| P3-1 offload | Deferred. | No attributed hotspot suitable for offload yet; GL context/swap/feedback paths above constrain candidates. | Consider only after upload, pack, audio, or write-call costs dominate including sync/transfer overhead. | Preserve frame order, context ownership, pacing, and synchronization. |
| P3-2 Vulkan/software | Separate status. | OpenGL path has native-wide fast-path controls at `runtime/src/gpu_gl_renderer.c:1604-1628` and mirror skipping at `runtime/src/gpu_gl_renderer.c:1801-1816`, `runtime/src/gpu_gl_renderer.c:1856-1868`. | Track Vulkan/software separately; do not use as OpenGL acceptance substitute. | Explicit backend coverage and parity. |
## Validation Gaps
Independent Windows CPU sampling was attempted with WPR's CPU profile. Starting
the trace failed with policy error `0xc5585011`; WPR subsequently reported no
active recording. No CPU sample attribution was obtained. Wall-time phase
counters under a Job CPU cap are not exclusive thread CPU time, including the
large observed pacer intervals. No pacing defect is established by those values.
The GP0 ring's current public entry contains 104 bytes on the tested MinGW ABI,
not the old source comment's implied size. At `1 << 20` entries its allocation
request is 104 MiB. This is capacity, not a measured resident-memory reduction.
Its only public readers found in the runtime are the debug server's GP0 history
commands. The copy-builder global `g_gp0_last_copy_sp` is referenced only by the
ring's own capture/record code. A production-only gate remains a candidate;
do not remove functional GP0 source tracking, command counters, or draw ordering
along with this history.
Known baseline structural-test failures are not caused by these candidates:
- `runtime/tests/test_runtime_perf_diag_guards.py:50` expects stale pacer-call
placement.
- `runtime/tests/test_interpreter_perf_guards.py:306` asserts inline cycle-limit
invalidation for listed GTE/device functions.
Root verified the same failures against immutable provenance under
`.local/ape-build-provenance/framework-8ec6498d/...`; treat them as validation
gaps before using those tests as acceptance for write-call work.
## Vulkan Qualification Audit
Read-only follow-up, 2026-09-05. No Vulkan build/run or performance claim.
The backend is substantially implemented: its vtable wires primitive rendering,
textures, masks, PGXP, transfers and native-wide callbacks (`gpu_vk_renderer.c`,
`vkb_backend` near line 3273). Its introductory phase-status comment is stale.
- `PSX_ENABLE_VULKAN=ON` is not proof of an active renderer. CMake only defines
`PSX_HAVE_VULKAN` after header/tool checks; otherwise `gpu_vk_renderer.c:35`
compiles inert stubs. Context initialization can also fail and select software
(`main.cpp:13623`). A real qualification run must witness the active backend.
- Readback is implemented but can force full VRAM pack/copy/wait through
`ensure_cpu` (`gpu_vk_renderer.c:2308`). It is not the normal 15-bit GPU-direct
present path. Missing evidence: mixed-command readback parity and counts
proving ordinary presentation does not unexpectedly enter this path.
- Native-wide presentation exists, but Vulkan omits the vtable's
`wide_dump_full` callback. `gr_wide_supported()` only checks
`render_wide_display`, so presentation support does not imply complete
diagnostic capture support (`gpu_render.c:165`, `gpu_render.h:197`).
- Netplay explicitly forces a Vulkan request to software because the present
path is not CPU-authoritative (`main.cpp:13075`). Preserve this correctness
guard; it is not an OpenGL optimization.
- Existing Vulkan tests cover source patterns and an upload-alignment helper,
not a live context drawing and comparing pixels. Required follow-up is a
backend matrix for primitive order, textures, blending, masks, feedback,
transfers, native-wide, depth24, capture, and initialization/fallback cases.
These findings justify a separate qualification track, not a recommendation to
switch users away from OpenGL or a conclusion that Vulkan is unimplemented.
## Local CPU Attribution Tools
Read-only inventory, 2026-09-05: WPR, xperf and WPA are installed, but the
attempted WPR CPU profile failed with policy error `0xc5585011`. PATH and
known-install-path checks found no AMD uProf, Intel VTune, Very Sleepy or
samply. This is a bounded inventory, not proof that no profiler exists anywhere
on the machine. No installation, elevation or policy change was attempted.
MinGW `gprof` is available, but requires an instrumented `-pg` rebuild and
cannot faithfully sample the existing Release artifact. `perf_host.py` supplies
whole-process CPU time, not stacks. Campaign phase counters are wall time, not
exclusive CPU time. `host_thread_probe.py` can suspend a same-user thread and
inspect its context/stack; repeated captures could establish coarse thread
stack occupancy with symbols, but are perturbing and must not be reported as
per-function CPU percentages. Cross-check such leads with controlled narrow
A/B experiments until a statistical CPU profiler can be used.
+107
View File
@@ -0,0 +1,107 @@
# Performance campaign tools
These tools begin P0-1 in the [campaign](../PERFORMANCE_CAMPAIGN.md). They collect
diagnostic evidence, not an automatic optimization acceptance verdict. See the
[experiment ledger](EXPERIMENTS.md) for verified local artifacts and limitations.
## Scoped host budget
Use a native Windows Python, not an MSYS path shim. For example, from the
campaign worktree in PowerShell:
```powershell
$python = 'C:\Users\Matthew\AppData\Local\Programs\Python\Python312\python.exe'
& $python tools/perf_host.py --core-percent 50 --affinity 0x1 --timeout 300 --report .local/host.json -- C:\path\ApeEscapeRecomp.exe --renderer opengl --no-launcher --debug-port 4680
```
Prepare isolated writable settings, saves, and memory cards before launch. The
launcher inherits its working directory; run from the isolated game directory
with an absolute script path when that is where the game expects its assets.
Preserve the title's required DLL search path and BIOS/disc configuration.
`--core-percent 50` budgets the entire job at half of one logical CPU equivalent;
`--cpu-percent 50` budgets half of the whole machine. Neither changes guest
clocks. Affinity is a separate control; holding it at one CPU also removes
parallelism. Compare identical affinity across runs, and separately test normal
affinity. Omit both percentage options for unrestricted execution. On Windows,
the child starts suspended, receives the job cap/affinity, then resumes hidden.
Timeout/error cleanup kills only the launched job. Report JSON includes applied
and read-back limits; stdout/stderr are separate adjacent log files.
Calibrate with a sustained CPU-time workload on each host. Short bursts can
escape effective throttling. A cap does not reproduce older GPU, cache, memory,
or power behavior. Keep real low-end machines in the acceptance matrix.
## Diagnostic route capture
Start a compatible Release build with `PSX_DEBUG_TOOLS=ON` and a local debug
port. Use the same effective settings, prepared overlays, BIOS, and isolated
checkpoint across samples. Do not expose the debug server to untrusted hosts.
```powershell
& $python tools/perf_campaign.py sample --port 4680 --route tools/perf_routes/ape_crabby_water_idle.json --restore-slot 1 --state-file .local/crabby-baseline/saves/scph1001/state_800A3660_slot01.pst --timeout 240 --out .local/A1.json --label A1 --provenance .local/target-A.json
```
The committed route references a local v5 state by SHA256; no game data is
distributed. It holds neutral digital input for 7,200 native route ticks. This
is the stationary water-facing workload, not the analog approach to the water.
The runner rejects analog axes because this runtime's native route protocol
does not support them. `--timeout` bounds route polling, not the entire tool
invocation; individual TCP calls, snapshots, and restore have additional time.
`--state-file` hashes an operator-supplied file; it does not upload it or change
the runtime's save directory. Verify that slot 1 actually resolves to that file.
The runner waits for a successful completed restore generation, not just the
staging acknowledgement. Without restore options, starting state is an operator
precondition. Guest execution continues during sequential setup/snapshots, so
this tool does not yet guarantee identical checkpoint-to-route guest work.
Samples retain raw before/after counters, route completion, phase attribution,
and partial failure evidence. Disabled or missing GL timer data is unavailable,
never zero. `summary.route_wall_s` includes route setup/status-poll overhead;
the counter observation window is longer. Neither is exclusive CPU busy time.
Paced route time alone will not reveal spare CPU headroom at full speed.
## Target provenance and comparison
`--provenance` takes a JSON object with these required fields:
| Field | Meaning |
| --- | --- |
| `artifact_sha256` | SHA256 of the actual running executable |
| `settings_sha256` | SHA256 of a retained manifest covering all effective config, overrides, and mods |
| `bios_sha256` | SHA256 of the BIOS input |
| `overlay_inventory_sha256` | SHA256 of a retained, sorted inventory including generated/cache artifact hashes |
| `host_id` | Stable host identity; retain OS, driver, actual GL device, power and display details alongside it |
| `cpu_budget` | `{"unrestricted": true}` or applied `{"cap": 313, "denominator": 10000, "affinity": "0x1"}` |
| `instrumentation` | Nonempty object recording build flags, profiler and diagnostic environment settings |
Record affinity even for unrestricted runs. Optional `variant_selector` names
the faithful/candidate selector. These declarations are operator evidence, not
attestation of the running process. Keep build/source/toolchain manifests and
host launcher reports with them. Samples without target provenance can be
collected but cannot be compared by the A/B command.
Collect at least three nonoverlapping pairs in A1, B1, A2, B2, A3, B3 order:
```powershell
& $python tools/perf_campaign.py ab --a .local/A1.json --b .local/B1.json --a .local/A2.json --b .local/B2.json --a .local/A3.json --b .local/B3.json --out .local/AB.json
```
Declare the primary metric and direction at capture time. Comparison requires
matching routes, metrics, and environments, stable artifacts/selectors within
each side, successful samples, and timestamp evidence of interleaving. It
reports raw values, best values, and medians. `--discard 2:compiler-activity`
removes the entire original second pair and retains its artifact references and
reason; at least three pairs must remain. Never discard merely slow samples.
An accepted *pair* only means comparison eligibility. Correctness checkpoints,
candidate hit witnesses, noise characterization, profiling-off production
timing, stutter/audio measurements, and low-end hardware qualification remain
separate required campaign gates.
## Tests
```powershell
& $python -m unittest tools.tests.test_perf_campaign tools.tests.test_perf_host tools.tests.test_stall_report
```
+1 -1
View File
@@ -1 +1 @@
5f69759d2f0a66ea1014b64a159f2f44fe3f79447972521209fb266b7e20e1de
012434af40660484f2a6990dd9f6b2acb35d00bc514c3e85ba0a49ca211f3d33
+1 -1
View File
@@ -1 +1 @@
439e85a797f227c8adbc8445832bb45438f357af54ff1ba834302c32003ad12e
aa351622ddd9c496b61bd0982e58230cb4fc3aac6e2a5a1e4899ccd51fc6dbe9
+2
View File
@@ -101,6 +101,8 @@ struct CodeGenConfig {
// nop replaced with `a1 += margin`, or move rD,a1 replaced with
// `rD = a1 + margin`. See config_loader.h. Empty = default.
std::set<uint32_t> ws_cull_bias_sites;
// Lower endpoint counterpart: addi/addiu immediate minus activation margin.
std::set<uint32_t> ws_cull_bias_lower_sites;
std::set<uint32_t> ws_cull_range_sites;
std::set<uint32_t> ws_cull_a1_sites;
std::set<uint32_t> ws_cull_screen_x_sites;
+11 -6
View File
@@ -1087,7 +1087,8 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
// same address holds different code across scene variants, so a mismatch is
// expected — apply the transform only where the bytes match, else fall
// through to the vanilla translation (see CodeGenConfig::overlay_mode).
if (config_.ws_cull_bias_sites.count(addr)) {
if (config_.ws_cull_bias_sites.count(addr) ||
config_.ws_cull_bias_lower_sites.count(addr)) {
if (opcode == 0x08 || opcode == 0x09) { // addi / addiu
uint32_t rs = get_rs(instr), rt = get_rt(instr);
int16_t imm = get_imm16(instr);
@@ -1097,9 +1098,13 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
"(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + {} : 0)",
config_.ws_cull_activation_guard_pixels)
: "psx_ws_x_margin()";
return fmt::format("{} = {} + ((int32_t){} + {});{}",
reg_name(rt), reg_name(rs), imm, margin,
comment);
std::string code = fmt::format("{} = {} + ((int32_t){} {} {});",
reg_name(rt), reg_name(rs), imm,
config_.ws_cull_bias_lower_sites.count(addr) ? "-" : "+", margin);
// Preserve the ordinary ALU shadow update, including source capture
// before an in-place add. Margin zero must also be a PGXP identity.
PSXRecomp::append_pgxp_hooks(instr, code);
return config_.indent + code + comment;
} else if (!config_.overlay_mode) {
fmt::print(stderr, "ERROR: [widescreen.cull] bias site 0x{:08X} is not "
"addi/addiu (opcode 0x{:02X})\n", addr, opcode);
@@ -2920,7 +2925,7 @@ GeneratedFunction CodeGenerator::generate_function(
if (config_.mod_function_entry_funcs.count(func.start_addr)) {
body_ss << config_.indent
<< fmt::format(
"psx_mod_function_entry(cpu, 0x{:08X}u);"
"if (psx_mod_function_entry(cpu, 0x{:08X}u)) return;"
" /* trusted opt-in game-mod hook */\n",
func.start_addr);
}
@@ -3433,7 +3438,7 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const {
ss << "#endif\n";
ss << "extern int psx_game_text_native_ok(uint32_t addr); /* stale-static guard (dispatch shard) */\n";
ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n";
ss << "extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n";
ss << "extern int psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n";
ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n";
ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n";
ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n";
+5
View File
@@ -64,6 +64,8 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) {
h.words("sprite_tag_funcs", c.ws_sprite_tag_funcs);
h.words("mod_function_entry_funcs", c.mod_function_entry_funcs);
h.words("cull_bias", c.ws_cull_bias_sites);
if (!c.ws_cull_bias_lower_sites.empty())
h.words("cull_bias_lower", c.ws_cull_bias_lower_sites);
h.words("cull_range", c.ws_cull_range_sites);
h.words("cull_a1", c.ws_cull_a1_sites);
h.words("cull_screen_x", c.ws_cull_screen_x_sites);
@@ -1755,6 +1757,7 @@ GameConfig load_game_config(const fs::path& config_path_in) {
// Optional [widescreen.cull] block — world-space draw-cull widening.
std::vector<uint32_t> ws_cull_bias_sites, ws_cull_range_sites, ws_cull_a1_sites;
std::vector<uint32_t> ws_cull_bias_lower_sites;
std::vector<uint32_t> ws_cull_screen_x_sites;
std::vector<uint32_t> ws_cull_slti_sites;
std::vector<uint32_t> ws_cull_slti_lower_sites;
@@ -1792,6 +1795,7 @@ GameConfig load_game_config(const fs::path& config_path_in) {
out.push_back(parse_hex(a, fmt::format("widescreen.cull.{}", key)));
};
load_sites("bias_sites", ws_cull_bias_sites);
load_sites("bias_lower_sites", ws_cull_bias_lower_sites);
load_sites("range_sites", ws_cull_range_sites);
load_sites("a1_sites", ws_cull_a1_sites);
load_sites("screen_x_sites", ws_cull_screen_x_sites);
@@ -2233,6 +2237,7 @@ GameConfig load_game_config(const fs::path& config_path_in) {
/*vsync_event_horizon_extra_sites*/ vsync_event_horizon_extra_sites,
/*vsync_event_horizon_any*/ vsync_event_horizon_any,
/*ws_cull_bias_sites*/ ws_cull_bias_sites,
/*ws_cull_bias_lower_sites*/ ws_cull_bias_lower_sites,
/*ws_cull_range_sites*/ ws_cull_range_sites,
/*ws_cull_a1_sites*/ ws_cull_a1_sites,
/*ws_cull_screen_x_sites*/ ws_cull_screen_x_sites,
+2
View File
@@ -918,6 +918,8 @@ struct GameConfig {
// rD = a1 + margin (caller-margin classifier variants)
// All Ghidra-evidenced; empty by default. Changing these requires a regen.
std::vector<uint32_t> ws_cull_bias_sites;
// Lower endpoint of a camera-relative activation/reset interval.
std::vector<uint32_t> ws_cull_bias_lower_sites;
std::vector<uint32_t> ws_cull_range_sites;
std::vector<uint32_t> ws_cull_a1_sites;
// Explicit `sltiu rt,sx,W` render rejects for cases where codegen function
+4
View File
@@ -224,6 +224,7 @@ static int psxrecomp_game_main(int argc, char** argv) {
std::set<uint32_t> load_charge_batch_funcs; // [recompiler] load_charge_batch*
std::map<uint32_t, std::array<uint32_t, 4>> vsync_query_hle_funcs;
std::set<uint32_t> ws_cull_bias, ws_cull_range, ws_cull_a1; // [widescreen.cull]
std::set<uint32_t> ws_cull_bias_lower;
std::set<uint32_t> ws_cull_screen_x; // [widescreen.cull] screen_x_sites
std::set<uint32_t> ws_cull_slti; // [widescreen.cull] slti_sites
std::set<uint32_t> ws_cull_slti_lower; // [widescreen.cull] slti_lower_sites
@@ -292,6 +293,7 @@ static int psxrecomp_game_main(int argc, char** argv) {
cfg.vsync_counter_addr, cfg.vsync_gpustat_ptr_addr,
cfg.vsync_timer1_ptr_addr, cfg.vsync_timer1_cache_addr };
ws_cull_bias.insert(cfg.ws_cull_bias_sites.begin(), cfg.ws_cull_bias_sites.end());
ws_cull_bias_lower.insert(cfg.ws_cull_bias_lower_sites.begin(), cfg.ws_cull_bias_lower_sites.end());
ws_cull_range.insert(cfg.ws_cull_range_sites.begin(), cfg.ws_cull_range_sites.end());
ws_cull_a1.insert(cfg.ws_cull_a1_sites.begin(), cfg.ws_cull_a1_sites.end());
ws_cull_screen_x.insert(cfg.ws_cull_screen_x_sites.begin(), cfg.ws_cull_screen_x_sites.end());
@@ -395,6 +397,7 @@ static int psxrecomp_game_main(int argc, char** argv) {
mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(),
wscfg.mod_function_entry_funcs.end());
ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end());
ws_cull_bias_lower.insert(wscfg.ws_cull_bias_lower_sites.begin(), wscfg.ws_cull_bias_lower_sites.end());
ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end());
ws_cull_a1.insert(wscfg.ws_cull_a1_sites.begin(), wscfg.ws_cull_a1_sites.end());
ws_cull_screen_x.insert(wscfg.ws_cull_screen_x_sites.begin(), wscfg.ws_cull_screen_x_sites.end());
@@ -1458,6 +1461,7 @@ static int psxrecomp_game_main(int argc, char** argv) {
codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs;
codegen_config.ws_bg2d_init_func = ws_bg2d_init_func;
codegen_config.ws_cull_bias_sites = ws_cull_bias;
codegen_config.ws_cull_bias_lower_sites = ws_cull_bias_lower;
codegen_config.ws_cull_range_sites = ws_cull_range;
codegen_config.ws_cull_a1_sites = ws_cull_a1;
codegen_config.ws_cull_screen_x_sites = ws_cull_screen_x;
@@ -346,13 +346,21 @@ result = 1
const auto range = write_config(root, "range-cull", R"toml(
[widescreen.cull]
range_sites = ["0x80012340"]
bias_lower_sites = ["0x80012348"]
activation_guard_pixels = 256
)toml");
const auto range_config = PSXRecompV4::load_game_config(range);
check(range_config.ws_cull_range_sites ==
std::vector<uint32_t>{0x80012340u} &&
range_config.ws_cull_bias_lower_sites ==
std::vector<uint32_t>{0x80012348u} &&
range_config.ws_cull_activation_guard_pixels == 256,
"parser preserves explicit range sites and activation guard");
auto changed_lower_config = range_config;
changed_lower_config.ws_cull_bias_lower_sites.clear();
check(PSXRecompV4::overlay_codegen_config_hash(range_config) !=
PSXRecompV4::overlay_codegen_config_hash(changed_lower_config),
"lower activation endpoint changes overlay cache identity");
const auto bad_activation_guard =
write_config(root, "range-cull-bad-activation-guard", R"toml(
@@ -774,6 +782,27 @@ void codegen_tests() {
std::string::npos,
"activation bias gains the same isolated resident-object lead");
PSXRecomp::CodeGenConfig lower_bias_config;
lower_bias_config.ws_cull_bias_lower_sites.insert(0x80010000u);
for (const uint32_t opcode : {0x2082FFD0u, 0x2482FFD0u}) {
const std::string lower = generate_first_instruction(opcode, {}, false, lower_bias_config);
check(lower.find("((int32_t)-48 - psx_ws_x_margin())") != std::string::npos,
"ADDI/ADDIU lower endpoint expands left without changing camera source");
check(lower.find("PGXP_ALU(") != std::string::npos &&
lower.find("_pgx1 = cpu->gpr[4]") != std::string::npos,
"lower bound preserves native ALU precision tracking");
}
lower_bias_config.ws_cull_activation_guard_pixels = 32;
const std::string lower_guard = generate_first_instruction(
0x2482FFD0u, {}, false, lower_bias_config);
check(lower_guard.find("-48 - (psx_ws_x_margin() > 0 ? psx_ws_x_margin() + 32 : 0)") !=
std::string::npos,
"lower endpoint includes activation guard only while wide");
const std::string lower_mismatch = generate_first_instruction(
0x3482FFD0u, {}, true, lower_bias_config); // ORI variant is not an add.
check(lower_mismatch.find("psx_ws_x_margin()") == std::string::npos,
"lower bias leaves a nonmatching overlay opcode unchanged");
PSXRecomp::CodeGenConfig branch_keep_config;
branch_keep_config.ws_cull_branch_keep_sites.insert(0x80010000u);
const std::string branch_keep = generate_first_instruction(
+42
View File
@@ -98,6 +98,10 @@ if(BUILD_TESTING)
"${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mirror_pc_preserved_guards.py")
add_test(NAME segment_miss_wiring_test COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/test_segment_miss_wiring.py")
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
add_test(NAME ws_activation_bias_exec_test COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ws_activation_bias_exec.py" "${CMAKE_C_COMPILER}")
endif()
add_test(NAME gpu_textured_dot_nw_shift_test COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gpu_textured_dot_nw_shift.py")
add_test(NAME gl_readback_runner_test COMMAND "${Python3_EXECUTABLE}"
@@ -413,9 +417,30 @@ if(BUILD_TESTING)
add_test(NAME gpu_textured_dot_nw_shift_exec_test
COMMAND gpu_textured_dot_nw_shift_exec_test)
add_executable(ws_view_anchor_test tests/test_ws_view_anchor.c)
add_executable(ws_view_anchor_gpu_test tests/test_ws_view_anchor_gpu.c
src/psx_video_timing.c)
foreach(_anchor_test ws_view_anchor ws_view_anchor_gpu)
target_include_directories(${_anchor_test}_test PRIVATE include src)
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${_anchor_test}_test PRIVATE -ffunction-sections -fdata-sections)
if(NOT APPLE)
target_link_options(${_anchor_test}_test PRIVATE -Wl,--gc-sections)
endif()
endif()
if(NOT WIN32)
target_link_libraries(${_anchor_test}_test PRIVATE m)
endif()
endforeach()
add_test(NAME ws_view_anchor_test COMMAND ws_view_anchor_test)
add_test(NAME ws_view_anchor_gpu_test COMMAND ws_view_anchor_gpu_test)
add_executable(ws_repeat_rect_test tests/test_ws_repeat_rect.c)
target_include_directories(ws_repeat_rect_test PRIVATE include)
add_test(NAME ws_repeat_rect_test COMMAND ws_repeat_rect_test)
add_executable(ws_screen_mask_test tests/test_ws_screen_mask.c)
target_include_directories(ws_screen_mask_test PRIVATE include)
add_test(NAME ws_screen_mask_test COMMAND ws_screen_mask_test)
add_executable(mod_gpu_dma_aperture_test
tests/test_mod_gpu_dma_aperture.c
@@ -931,6 +956,21 @@ if(BUILD_TESTING)
find_package(Python3 COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND)
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang" AND NOT MSVC
AND NOT CMAKE_CROSSCOMPILING)
add_test(NAME audio_trace_pcm_guard_test
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_audio_trace_pcm_guard.py
--cc ${CMAKE_C_COMPILER})
add_test(NAME gpu_gp0_history_guard_test
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gpu_gp0_history_guard.py
--cc ${CMAKE_C_COMPILER})
add_test(NAME memory_debug_trace_guard_test
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_memory_debug_trace_guard.py
--cc ${CMAKE_C_COMPILER})
endif()
add_test(NAME fntrace_c_linkage_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fntrace_c_linkage.py)
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
add_test(NAME overlay_retry_c11_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_retry_c11.py --compiler ${CMAKE_C_COMPILER})
@@ -943,6 +983,8 @@ if(BUILD_TESTING)
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_disc_picker_ascii.py)
add_test(NAME disc_companion_test COMMAND ${Python3_EXECUTABLE} ${PSXRECOMP_ROOT}/tools/tests/test_disc_companion.py)
add_test(NAME sbi_registry_test COMMAND ${Python3_EXECUTABLE} ${PSXRECOMP_ROOT}/tools/tests/test_sbi_registry.py)
add_test(NAME perf_campaign_test COMMAND ${Python3_EXECUTABLE} ${PSXRECOMP_ROOT}/tools/tests/test_perf_campaign.py)
add_test(NAME perf_host_test COMMAND ${Python3_EXECUTABLE} ${PSXRECOMP_ROOT}/tools/tests/test_perf_host.py)
add_test(NAME frame_interpolation_context_ownership_test
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_interpolation_context_ownership.py)
+11 -7
View File
@@ -1,10 +1,10 @@
/*
* audio_trace.h - always-on audio observability rings (PCM taps + event ring).
* audio_trace.h - audio health counters, event ring and debug PCM history.
*
* Port of the snesrecomp audio-campaign measurement harness (audio_trace.{c,h},
* commit 67ead27 lineage) to the PSX pipeline, following the cross-system tap
* model in F:/Projects/_audio_round2/ROUND2_PLAN.md: always-on ring buffers
* recording every sample/event from process start, dumped AFTER the fact via
* model in F:/Projects/_audio_round2/ROUND2_PLAN.md: debug-tool builds keep
* always-on ring buffers recording from process start, dumped AFTER the fact via
* TCP debug commands — never arm-then-reproduce.
*
* Taps (PSX pipeline):
@@ -21,9 +21,11 @@
* output — giving symmetric `audio_*` commands on ports 4370 and 4380.
*
* Threading: each tap has a single writer. Readers (debug-server thread)
* take a stable [oldest, head) snapshot; the PCM rings hold ~95 s at 44100,
* so a dump cannot be lapped mid-read in practice. Indices are C11 atomics
* (release on publish, acquire on read).
* take a stable [oldest, head) snapshot; in debug-tool builds the PCM rings
* hold ~95 s at 44100, so a dump cannot be lapped mid-read in practice.
* Production PSX_NO_DEBUG_TOOLS builds keep counters/events but compile out
* PCM history and WAV dumps. Indices are C11 atomics (release on publish,
* acquire on read).
*/
#ifndef PSX_AUDIO_TRACE_H
#define PSX_AUDIO_TRACE_H
@@ -118,7 +120,9 @@ uint32_t audio_trace_events_get(AudioTraceEvent *out, uint32_t max);
/* Write a tap slice as a stereo s16 WAV at the tap's rate. start=-1,count=0
* dumps the whole currently-buffered ring. `start`/`count` are absolute frame
* indices on the tap's production timeline. Returns frames written, or -1 on
* error (unwritable path / empty ring / slice already evicted). */
* error (unwritable path / empty ring / slice already evicted). In
* PSX_NO_DEBUG_TOOLS production builds, PCM history is not present and this
* returns -1 without creating/opening `path`. */
int64_t audio_trace_dump_wav(int tap, const char *path,
int64_t start, uint64_t count);
+44 -4
View File
@@ -109,11 +109,12 @@ typedef struct {
void gpu_arm_shaded_quad_capture(void);
int gpu_get_shaded_quad_capture(const GpuSqCapEntry** out);
/* Per-frame GP0 command ring (always-on; query via debug server).
* Each entry records the GP0 command header + up to 6 payload words
* (longer commands like 0x3C shaded textured quad are truncated to 6).
/* Per-frame GP0 command ring (always-on in debug-tool builds).
* Each entry records up to GPU_GP0_RING_MAX_WORDS command words, including
* the header; longer commands are truncated.
* Stamped with the s_frame_count value at issue time so a debug
* client can pull all commands for any frame in the ring window. */
* client can pull all commands for any frame in the ring window.
* PSX_NO_DEBUG_TOOLS builds report zero total/capacity and empty dumps/spans. */
#define GPU_GP0_RING_MAX_WORDS 12
typedef struct {
uint32_t frame;
@@ -175,6 +176,33 @@ void gpu_ws_configure(int aspect_num, int aspect_den,
/* [widescreen] full_2d: opt a pure-2D sprite game into the widescreen present
* path (treat every in-game frame as gameplay, since it never tags 3D prims). */
void gpu_ws_set_full_2d(int on);
/* Signed 16-bit camera/min/max; active is a nonzero byte. Requires bg2d hooks.
* Zero addresses disable the feature. No guest memory is written. */
void gpu_ws_set_view_anchor(uint32_t camera, uint32_t min, uint32_t max, uint32_t active);
/* Optional authored scene bounds when native camera locks do not delimit
* visible geometry. Set before layer setup; disable when leaving the scene.
* Changes presentation only, and has no effect without view anchoring. */
void gpu_ws_set_view_bounds_override(int enabled, int minimum, int maximum);
/* Bracket each bg2d packet producer, with its guest packet pointer. The
* independent-layer mask identifies parallax backdrops that may anchor to
* their own map edges; linked/foreground layers retain the world origin. */
void gpu_ws_bg2d_begin_view_layer(unsigned layer, uint32_t packet, unsigned independent_mask);
void gpu_ws_bg2d_end_view_layer(unsigned layer, uint32_t packet);
/* Opt-in host-produced SPRT_16 packets, 32-byte slots in enhancement DMA RAM.
* The 16-byte standard packet is followed by signed view shift, left/right
* padding and GPU_WS_BG2D_PACKET_MAGIC, optionally ORed with MIRROR_X.
* Coordinates use signed 16-bit X; ordinary
* packets retain PS1 signed 11-bit coordinates. The guest tile loop/ring stays
* native while this arena is registered. Allocate once during mod activation. */
void gpu_ws_bg2d_set_host_arena(uint32_t base, uint32_t size);
#define GPU_WS_BG2D_PACKET_MAGIC 0x58364247u
#define GPU_WS_BG2D_BANK_PACKET_MAGIC 0x58364248u
/* Bank packets pack signed shift in +16 low half and immutable bank ID in
* its high half. Their indices use the bank, but palettes remain live VRAM.
* Keep the original magic readable for pending packets in older snapshots. */
#define GPU_WS_BG2D_MIRROR_X 0x80000000u
struct WsViewAnchor;
int gpu_ws_bg2d_get_view(unsigned layer, struct WsViewAnchor *view);
void gpu_ws_set_auto_ui_squash(int on);
/* [widescreen.bg2d] Capcom 2D background tile-loop widen — hooked at the renderer's
* column-count / start-tile-col / start-screen-x instructions. Identity at 4:3
@@ -287,6 +315,8 @@ void gpu_ws_set_aspect_cone(const uint32_t *addresses,
const uint32_t queue_capacities[3],
const uint32_t queue_type_masks[3]);
int psx_ws_is_cull_bias_site(uint32_t pc);
void gpu_ws_set_bias_lower_cull_sites(const uint32_t *sites, int nsites);
int psx_ws_is_cull_bias_lower_site(uint32_t pc);
int psx_ws_is_cull_slti_site(uint32_t pc);
int psx_ws_is_cull_slti_lower_site(uint32_t pc);
int psx_ws_is_cull_negsub_site(uint32_t pc);
@@ -392,6 +422,14 @@ void gpu_ws_tag_black_reveal_rect(uint32_t prim);
* reveal margins only. The caller verifies the composite's source period.
* Original UVs, texel density, and canonical VRAM writes are unchanged. */
void gpu_ws_tag_repeat_rect(uint32_t prim, int32_t period);
/* Title-identified flat screen mask: extend its off-screen vertical boundary
* to the adaptive edge, preserving the interior opening and canonical image.
* Packet guarded, cleared on reset/load, inert outside native-wide gameplay. */
void gpu_ws_tag_screen_mask_quad(uint32_t prim);
/* Pure emulation-thread predicate for a known native-only effect/scene.
* Nonzero forces native 4:3; zero defers. No GPU calls or guest writes inside.
* Host configuration survives reset/load; classification reads live state. */
void gpu_ws_set_native_scene_predicate(int (*predicate)(void));
/* Targeted alternative for sprite-heavy 2D games: corner-anchor only primitives
* whose ordering-table packet lives in the configured half-open RAM range. */
void gpu_ws_set_nw_left_hud_packet_range(uint32_t lo, uint32_t hi);
@@ -486,7 +524,9 @@ typedef struct {
int xnum, xden; /* squash factor */
int mode; /* 0 = off, 1 = squash, 2 = native-wide */
int nw_extra; /* native-wide frame growth (display px), 0 if off */
int view_anchor, view_left, view_right, view_shift, view_pad_left, view_pad_right;
uint64_t cur_frame;
uint32_t bg2d_generations; /* Game background submissions, independent of vblank. */
uint32_t last_tag_frame; /* frame of newest tagged prim */
uint32_t last_3d_frame; /* frame of newest shaded prim (diagnostic) */
uint32_t gte_verts; /* RTPS/RTPT verts in the last completed frame */
+2
View File
@@ -21,6 +21,8 @@ int gl_renderer_init_context(struct SDL_Window *win);
/* Select a retained immutable bank for the next textured submission; zero
* selects live VRAM. Emulation/GL owning thread only. Returns 0 if unavailable. */
int gl_renderer_select_texture_bank(uint16_t id);
/* Retain texture indices while sampling current guest CLUTs (fades/animation). */
int gl_renderer_select_texture_bank_live_clut(uint16_t id);
int gl_renderer_texture_banks_supported(void);
/* Set the GL swap interval / vsync mode (1=vsync, 0=immediate, -1=adaptive).
+5
View File
@@ -117,6 +117,10 @@ void gr_set_draw_offset(int x, int y);
* the canonical present path. */
int gr_wide_supported(void);
void gr_wide_configure(int wide_w, int offset);
/* Presentation-only origin and padding, independent of the symmetric budget.
* enabled also forbids reusing the canonical center: HUD and world may use
* different origins. Backends must flush queued draws before changing this. */
void gr_wide_set_view(int enabled, int shift, int pad_left, int pad_right);
void gr_wide_set_target(int base_x);
void gr_wide_disable_target(void);
void gr_wide_clear(int base_x, int y, int h, uint16_t color);
@@ -194,6 +198,7 @@ typedef struct GpuRenderBackend {
* facade then reports gr_wide_supported() == 0 and the caller keeps the
* canonical present). */
void (*wide_configure)(int wide_w, int offset);
void (*wide_set_view)(int enabled, int shift, int pad_left, int pad_right);
void (*wide_set_target)(int base_x);
void (*wide_disable_target)(void);
void (*wide_clear)(int base_x, int y, int h, uint16_t color);
+1
View File
@@ -109,6 +109,7 @@ void sw_set_draw_offset(int x, int y);
* base_x. wide_w<=0 disables. set_target selects/clears the mirror per back
* buffer; render_wide_display is the present source for the displayed buffer. */
void sw_wide_configure(int wide_w, int offset);
void sw_wide_set_view(int enabled, int shift, int pad_left, int pad_right);
void sw_wide_set_target(int base_x);
void sw_wide_disable_target(void);
void sw_wide_clear(int base_x, int y, int h, uint16_t color);
+3
View File
@@ -523,12 +523,15 @@ bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void));
* select it by id, and it runs only while a resolved plan activates that id. */
bool mod_register_function_entry_plugin(const std::string& id, uint32_t address,
PSXModFunctionEntryCallback callback);
bool mod_register_function_filter_plugin(const std::string& id, uint32_t address,
PSXModFunctionFilterCallback callback);
bool mod_plugin_registered(const std::string& id);
void mod_invoke_activation_plugin(const std::string& id);
void mod_invoke_vblank_plugin(const std::string& id);
struct ModFunctionEntryHook {
uint32_t address = 0;
PSXModFunctionEntryCallback callback = nullptr;
PSXModFunctionFilterCallback filter = nullptr;
};
/* Hooks one implementation registered, in registration order. mod_runtime
* flattens these into an address table when the plan's plugins activate. */
+10 -1
View File
@@ -11,6 +11,10 @@ typedef void (*PSXModActivationCallback)(void);
struct CPUState;
typedef void (*PSXModFunctionEntryCallback)(struct CPUState* cpu,
uint32_t address);
/* Return nonzero to finish this opt-in function with the callback's return
* registers. The runtime publishes pc=$ra; zero executes the original body. */
typedef int (*PSXModFunctionFilterCallback)(struct CPUState* cpu,
uint32_t address);
/*
* Register a trusted, statically linked plugin implementation. Package
@@ -27,10 +31,15 @@ int psx_mod_register_function_entry_plugin(
* interpreted entry, so the hook contract does not depend on the backend.
* Hooks match by code address (segment bits ignored) and run only for plugins
* the active plan resolved; the table is rebuilt at plugin activation. */
void psx_mod_function_entry(struct CPUState* cpu, uint32_t address);
int psx_mod_register_function_filter_plugin(
const char* id, uint32_t address, PSXModFunctionFilterCallback callback);
int psx_mod_function_entry(struct CPUState* cpu, uint32_t address);
/* Active function-entry hook count (0 = none). Hot callers test it before the
* call, so a run without an active hook pays one load per interpreted entry. */
extern uint32_t g_psx_mod_function_entry_hooks;
/* Entry callbacks can make nested guest calls while retaining host registers.
* Save/load and rewind must wait until that host context has returned. */
int psx_mod_function_entry_active(void);
/* Narrow guest services available to trusted plugin callbacks. */
int psx_mod_game_started(void);
+4 -2
View File
@@ -112,7 +112,9 @@
* DLLs used to keep a private copy, so an overlay store left the host's
* value naming an older store. The DLL now writes through this pointer
* (cpu_state.h maps the name in PSX_OVERLAY_DLL_BUILD). */
#define PSX_OVERLAY_ABI_VERSION 24
/* v25: function-entry callbacks may return an opt-in filter result while
* retaining the v24 last_store_pc pointer. */
#define PSX_OVERLAY_ABI_VERSION 25
/* Process-lifetime overlay candidate capacity. Every accepted manifest F
* record consumes one slot, even when another DLL carries an identical
@@ -350,7 +352,7 @@ typedef struct OverlayCallbacks {
* emitter change (Phase 2 of docs/ENHANCEMENTS.md G1 value propagation). */
const PGXPHooks *pgxp;
int32_t (*ws_screen_x_bound)(int32_t vanilla);
void (*mod_function_entry)(CPUState *cpu, uint32_t address);
int (*mod_function_entry)(CPUState *cpu, uint32_t address);
/* The host's g_debug_last_store_pc (ABI v24; see the version-history note
* above). Overlay stores write it exactly as static and interpreted stores
* do. NULL leaves the DLL writing a private copy nothing reads. */
@@ -247,9 +247,9 @@ int32_t psx_ws_player_x_bound(int32_t vanilla) {
int32_t psx_ws_screen_x_bound(int32_t vanilla) {
return g_cbs.ws_screen_x_bound ? g_cbs.ws_screen_x_bound(vanilla) : vanilla;
}
void psx_mod_function_entry(CPUState *cpu, uint32_t address) {
int psx_mod_function_entry(CPUState *cpu, uint32_t address) {
overlay_flush_cycles();
if (g_cbs.mod_function_entry) g_cbs.mod_function_entry(cpu, address);
return g_cbs.mod_function_entry ? g_cbs.mod_function_entry(cpu, address) : 0;
}
int32_t psx_ws_depth_bound(int32_t imm) {
return g_cbs.ws_depth_bound ? g_cbs.ws_depth_bound(imm) : imm;
+27
View File
@@ -0,0 +1,27 @@
#ifndef PSXRECOMP_WS_SCREEN_MASK_H
#define PSXRECOMP_WS_SCREEN_MASK_H
#include <stdint.h>
/* Extend only an off-screen vertical boundary of a title-identified mask.
* The interior/slanted light boundary stays at its original coordinates.
* Result is wholly outside canonical VRAM; no triangle size limit applies. */
typedef struct { int x, y, w, h; } WsScreenMaskBand;
static inline int ws_screen_mask_band(const int32_t x[4], const int32_t y[4],
int width, int height, int margin,
WsScreenMaskBand *out) {
if (!out || width <= 0 || height <= 0 || margin <= 0 ||
y[0] != y[1] || y[2] != y[3] || y[0] != 0 || y[2] != height)
return 0;
if (x[0] == x[2] && x[0] <= 0 && x[1] > x[0] && x[3] > x[0] &&
x[0] > -margin) {
*out = (WsScreenMaskBand){-margin, 0, margin + x[0], height};
return 1;
}
if (x[1] == x[3] && x[1] >= width && x[0] < x[1] && x[2] < x[1] &&
x[1] < width + margin) {
*out = (WsScreenMaskBand){x[1], 0, width + margin - x[1], height};
return 1;
}
return 0;
}
#endif
+29
View File
@@ -0,0 +1,29 @@
#ifndef PSX_WS_VIEW_ANCHOR_H
#define PSX_WS_VIEW_ANCHOR_H
/* Host presentation geometry. Camera bounds describe the native view's left
* edge; the room's right edge is camera_max + native_width. */
typedef struct WsViewAnchor {
int left, right;
int shift;
int pad_left, pad_right;
} WsViewAnchor;
static inline WsViewAnchor ws_view_anchor(int extra, int camera,
int camera_min, int camera_max) {
WsViewAnchor v = {extra, extra, 0, 0, 0};
if (extra <= 0 || camera_min > camera_max ||
camera < camera_min || camera > camera_max) return v;
int avail_left = camera - camera_min;
int avail_right = camera_max - camera;
int total = 2 * extra;
v.left = avail_left < extra ? avail_left : extra;
v.right = avail_right < total - v.left ? avail_right : total - v.left;
v.left = avail_left < total - v.right ? avail_left : total - v.right;
int padding = total - v.left - v.right;
v.pad_left = padding / 2;
v.pad_right = padding - v.pad_left;
v.shift = v.left + v.pad_left - extra;
return v;
}
#endif
+22 -5
View File
@@ -1,11 +1,10 @@
/*
* audio_trace.c - always-on audio observability rings. See audio_trace.h.
*
* Memory: 3 PCM taps x 2^22 stereo frames x 4 bytes = 48 MiB static, plus a
* 2^19-entry event ring (16 MiB). Static BSS, zero cost until touched; the
* recording hot path is a memcpy per audio block and is compiled into
* Release/production builds too (always-on ring-buffer discipline — probes
* query history, they never arm recording).
* Debug-tool builds keep 4 PCM taps x 2^22 stereo frames x 4 bytes = 64 MiB
* static history, plus a 2^19-entry event ring (16 MiB). Production
* PSX_NO_DEBUG_TOOLS builds retain counters/events but compile out PCM history
* storage and WAV dumps.
*/
#include "audio_trace.h"
@@ -14,8 +13,10 @@
#include <string.h>
/* 2^22 frames @ 44100 ~= 95 s per tap. Power of two so wrap is a mask. */
#ifndef PSX_NO_DEBUG_TOOLS
#define PCM_RING_FRAMES (1u << 22)
#define PCM_RING_MASK (PCM_RING_FRAMES - 1u)
#endif
#define EV_RING_CAP (1u << 19)
#define EV_RING_MASK (EV_RING_CAP - 1u)
@@ -26,7 +27,9 @@
#define AUDIBLE_ABS 256
typedef struct {
#ifndef PSX_NO_DEBUG_TOOLS
int16_t pcm[PCM_RING_FRAMES * 2];
#endif
_Atomic uint64_t head; /* frames ever written; ring pos = head & MASK */
uint64_t nonzero;
uint64_t audible;
@@ -98,9 +101,11 @@ void audio_trace_pcm(int tap, const int16_t *stereo, int frames)
for (int f = 0; f < frames; f++) {
int16_t l = stereo[f * 2 + 0];
int16_t r = stereo[f * 2 + 1];
#ifndef PSX_NO_DEBUG_TOOLS
uint32_t pos = (uint32_t)((head + (uint64_t)f) & PCM_RING_MASK);
t->pcm[pos * 2 + 0] = l;
t->pcm[pos * 2 + 1] = r;
#endif
int32_t al = l < 0 ? -(int32_t)l : l;
int32_t ar = r < 0 ? -(int32_t)r : r;
int32_t a = al > ar ? al : ar;
@@ -187,6 +192,7 @@ uint32_t audio_trace_events_get(AudioTraceEvent *out, uint32_t max)
/* ---- WAV dump --------------------------------------------------------- */
#ifndef PSX_NO_DEBUG_TOOLS
static void wav_write_header(FILE *fp, uint32_t data_bytes, uint32_t rate)
{
uint8_t h[44];
@@ -261,3 +267,14 @@ int64_t audio_trace_dump_wav(int tap, const char *path,
fclose(fp);
return (int64_t)count;
}
#else
int64_t audio_trace_dump_wav(int tap, const char *path,
int64_t start, uint64_t count)
{
(void)tap;
(void)path;
(void)start;
(void)count;
return -1;
}
#endif
+6
View File
@@ -5379,6 +5379,9 @@ static void handle_gpu_state(int id, const char *json)
"\"present_native_43\":%d,\"x_margin\":%d,"
"\"activation_margin\":%d,\"squash\":[%d,%d],"
"\"mode\":%d,\"nw_extra\":%d,"
"\"view_anchor\":%d,\"view_left\":%d,\"view_right\":%d,"
"\"view_shift\":%d,\"view_padding\":[%d,%d],"
"\"bg2d_generations\":%u,"
"\"cur_frame\":%llu,\"last_tag_frame\":%u,\"last_3d_frame\":%u,"
"\"gte_verts\":%u,\"last_world3d_frame\":%u,"
"\"ovh_prims\":%u,\"last_ovh_frame\":%u,"
@@ -5409,6 +5412,9 @@ static void handle_gpu_state(int id, const char *json)
ws.present_native_43, ws.x_margin, ws.activation_margin,
ws.xnum, ws.xden,
ws.mode, ws.nw_extra,
ws.view_anchor, ws.view_left, ws.view_right, ws.view_shift,
ws.view_pad_left, ws.view_pad_right,
ws.bg2d_generations,
(unsigned long long)ws.cur_frame, ws.last_tag_frame,
ws.last_3d_frame, ws.gte_verts, ws.last_world3d_frame,
ws.ovh_prims, ws.last_ovh_frame,
+16 -7
View File
@@ -2038,7 +2038,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn,
cpu->gpr[rt] = widened;
else
cpu->gpr[rt] = a + (uint32_t)simm
+ (psx_ws_is_cull_bias_site(pc)
+ (psx_ws_is_cull_bias_lower_site(pc)
? 0u - (uint32_t)psx_ws_activation_margin()
: psx_ws_is_cull_bias_site(pc)
? (uint32_t)psx_ws_activation_margin() : 0u);
psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, (uint32_t)simm);
cpu->gpr[0] = 0;
@@ -2054,7 +2056,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn,
cpu->gpr[rt] = (uint32_t)psx_ws_screen_x_bound(simm);
else
cpu->gpr[rt] = a + (uint32_t)simm
+ (psx_ws_is_cull_bias_site(pc)
+ (psx_ws_is_cull_bias_lower_site(pc)
? 0u - (uint32_t)psx_ws_activation_margin()
: psx_ws_is_cull_bias_site(pc)
? (uint32_t)psx_ws_activation_margin() : 0u);
psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, (uint32_t)simm);
cpu->gpr[0] = 0;
@@ -2972,8 +2976,8 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
* to one load per interpreted entry. */
{
extern uint32_t g_psx_mod_function_entry_hooks;
extern void psx_mod_function_entry(CPUState *cpu, uint32_t address);
if (g_psx_mod_function_entry_hooks) psx_mod_function_entry(cpu, addr);
extern int psx_mod_function_entry(CPUState *cpu, uint32_t address);
if (g_psx_mod_function_entry_hooks && psx_mod_function_entry(cpu, addr)) return 1;
}
/* Per-PC entry counter (visible via dirty_ram_stats). */
@@ -3369,9 +3373,14 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
* function-entry hooks as a surfaced interpreter entry. */
{
extern uint32_t g_psx_mod_function_entry_hooks;
extern void psx_mod_function_entry(CPUState *, uint32_t);
if (g_psx_mod_function_entry_hooks)
psx_mod_function_entry(cpu, target);
extern int psx_mod_function_entry(CPUState *, uint32_t);
if (g_psx_mod_function_entry_hooks &&
psx_mod_function_entry(cpu, target)) {
g_dirty_ram_blocks_run++;
if (pc_entry) pc_entry->insns += (uint64_t)insns_executed;
g_dirty_interp_chain_target = cpu->pc;
OV_FPLOG_RET1();
}
}
pc = target;
current_page = target_phys >> 12;
+316 -26
View File
@@ -21,6 +21,7 @@
#include "gpu_sw_renderer.h"
#include "gpu_vram_dirty.h"
#include "gpu_render.h"
#include "ws_view_anchor.h"
#include "text_xlate.h"
#include "crash_trace.h"
#include "debug_server.h"
@@ -42,6 +43,7 @@
#include "ws_prepass_guard.h"
#include "ws_hud_anchor.h"
#include "ws_repeat_rect.h"
#include "ws_screen_mask.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -182,6 +184,7 @@ typedef struct { uint32_t key; uint32_t stamp; int32_t anchor_x; } WsTag;
static WsTag ws_tags[WS_TAG_BUCKETS];
static WsHudAnchorTag ws_hud_anchor_tags[WS_HUD_ANCHOR_TABLE_SIZE];
static WsHudAnchorTag ws_reveal_clear_tags[WS_HUD_ANCHOR_TABLE_SIZE];
static WsHudAnchorTag ws_screen_mask_tags[WS_HUD_ANCHOR_TABLE_SIZE];
static WsRepeatRectTag ws_repeat_rect_tags[WS_REPEAT_RECT_TAG_TABLE_SIZE];
static uint32_t ws_last_tag_stamp = (uint32_t)-1000; /* frame of newest tag */
static uint32_t ws_last_3d_stamp = (uint32_t)-1000; /* frame of newest shaded prim (diagnostic) */
@@ -413,6 +416,7 @@ static void ws_reset_scene_history(void) {
static uint32_t s_ws_fmv_frame_cache = 0xFFFFFFFFu;
static int s_ws_fmv_cached = 0;
static PSXModRetainedScenePredicate s_ws_retained_scene_predicate;
static int (*s_ws_native_scene_predicate)(void);
static WsSceneHold s_ws_scene_hold;
static uint32_t ws_display_origin(void);
@@ -421,8 +425,14 @@ void psx_mod_set_retained_scene_predicate(PSXModRetainedScenePredicate predicate
ws_scene_hold_reset(&s_ws_scene_hold);
}
void gpu_ws_set_native_scene_predicate(int (*predicate)(void)) {
s_ws_native_scene_predicate = predicate;
}
int gpu_ws_present_native_43(void) {
if (!ws_engaged()) return 0;
if (ws_mode == 2 && s_ws_native_scene_predicate && s_ws_native_scene_predicate())
return 1;
int game_mode = ws_game_mode();
if (!game_mode) ws_scene_latch.confirmed = 0;
int native_43 = !game_mode || ws_2d_only_scene();
@@ -519,6 +529,50 @@ static int ws_nw_offset(void) {
}
int ws_nw_extra(void) { return 2 * ws_nw_offset(); }
static uint32_t ws_view_camera_addr, ws_view_min_addr, ws_view_max_addr, ws_view_active_addr;
static int ws_view_bounds_override, ws_view_bounds_min, ws_view_bounds_max;
static WsViewAnchor ws_view;
static uint32_t ws_view_frame = UINT32_MAX;
void gpu_ws_set_view_anchor(uint32_t camera, uint32_t min, uint32_t max, uint32_t active) {
ws_view_camera_addr = camera;
ws_view_min_addr = min;
ws_view_max_addr = max;
ws_view_active_addr = active;
ws_view_bounds_override = 0;
ws_view_frame = UINT32_MAX;
memset(&ws_view, 0, sizeof(ws_view));
gr_wide_set_view(0, 0, 0, 0);
}
void gpu_ws_set_view_bounds_override(int enabled, int minimum, int maximum) {
ws_view_bounds_override = enabled && minimum <= maximum;
ws_view_bounds_min = minimum;
ws_view_bounds_max = maximum;
}
static int ws_view_enabled(void) {
return ws_view_camera_addr && ws_view_min_addr && ws_view_max_addr &&
ws_view_active_addr && ws_native_wide_active() && ws_disp_w() <= 384;
}
static WsViewAnchor ws_view_current(void) {
WsViewAnchor centered = {ws_nw_offset(), ws_nw_offset(), 0, 0, 0};
if (!ws_view_enabled() || (uint32_t)s_frame_count - ws_view_frame > 1u)
return centered;
return ws_view;
}
/* Sample at the BG renderer setup, after guest camera clamp and before either
* ring refill or packet generation. Intermediate camera writes are ignored. */
static void ws_view_sample(void) {
if (!ws_view_enabled()) return;
ws_view = ws_view_anchor(ws_nw_offset(),
(int16_t)psx_read_half(ws_view_camera_addr),
ws_view_bounds_override ? ws_view_bounds_min : (int16_t)psx_read_half(ws_view_min_addr),
ws_view_bounds_override ? ws_view_bounds_max : (int16_t)psx_read_half(ws_view_max_addr));
if (!psx_read_byte(ws_view_active_addr)) {
ws_view.left = ws_view.right = ws_nw_offset();
ws_view.shift = ws_view.pad_left = ws_view.pad_right = 0;
}
ws_view_frame = (uint32_t)s_frame_count;
}
int ws_nw_present_width(void) {
int wide_w = 0;
if (ws_local_viewport_layout(NULL, NULL, &wide_w, NULL))
@@ -629,6 +683,18 @@ void gpu_ws_set_explicit_cull_sites(const uint32_t *bias, int nbias,
int psx_ws_is_cull_bias_site(uint32_t pc) {
return ws_explicit_site(ws_explicit_bias_sites, ws_explicit_bias_n, pc);
}
static uint32_t ws_explicit_bias_lower_sites[WS_EXPLICIT_CULL_SITES_MAX];
static int ws_explicit_bias_lower_n = 0;
void gpu_ws_set_bias_lower_cull_sites(const uint32_t *sites, int nsites) {
if (nsites < 0) nsites = 0;
if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX;
ws_explicit_bias_lower_n = nsites;
for (int i = 0; i < nsites; i++)
ws_explicit_bias_lower_sites[i] = sites[i] & 0x1FFFFFFFu;
}
int psx_ws_is_cull_bias_lower_site(uint32_t pc) {
return ws_explicit_site(ws_explicit_bias_lower_sites, ws_explicit_bias_lower_n, pc);
}
int psx_ws_is_cull_slti_site(uint32_t pc) {
return ws_explicit_site(ws_explicit_slti_sites, ws_explicit_slti_n, pc);
}
@@ -1145,7 +1211,10 @@ int psx_ws_x_margin(void) {
* returning zero until the first 3D frame permanently bakes a 4:3 frustum
* into those lists. */
if (ws_native_wide_configured())
return ws_nw_configured_offset() + ws_cull_guard_pixels;
/* A constant envelope covers both possible edge origins. Do not shrink
* actor activation as the view slides, or prebuilt spawn lists go stale. */
return ws_nw_configured_offset() * (ws_view_camera_addr ? 2 : 1)
+ ws_cull_guard_pixels;
if (!ws_active()) return 0;
return (160 * (ws_xden - ws_xnum) + ws_xnum / 2) / ws_xnum
+ ws_cull_guard_pixels;
@@ -1214,6 +1283,108 @@ static uint32_t g_bg2d_layer_struct_stride = 0x54;
static uint32_t g_bg2d_packet_cap = 1000;
static int g_bg2d_native_cols = 21;
static int g_bg2d_parent_links = 1;
static uint32_t g_bg2d_host_base, g_bg2d_host_size;
static uint32_t g_bg2d_host_generation;
void gpu_ws_bg2d_set_host_arena(uint32_t base, uint32_t size) {
g_bg2d_host_base = base & 0x1fffffffu;
g_bg2d_host_size = size >= 32u && !(base & 31u) ? size : 0u;
}
static uint32_t ws_bg2d_host_packet(void) {
uint32_t command = gp0_cmd_source_addr & 0x1fffffffu;
if (!g_bg2d_host_size || command < g_bg2d_host_base + 4u) return 0;
uint32_t offset = command - g_bg2d_host_base - 4u;
if ((offset & 31u) || offset > g_bg2d_host_size - 32u) return 0;
uint32_t packet = command - 4u;
uint32_t magic = psx_read_word(packet + 28u) & ~GPU_WS_BG2D_MIRROR_X;
return magic == GPU_WS_BG2D_PACKET_MAGIC || magic == GPU_WS_BG2D_BANK_PACKET_MAGIC ? packet : 0;
}
#define WS_VIEW_LAYERS 8
static int ws_view_layer = -1;
static unsigned ws_view_independent_mask;
static struct {
uint32_t begin, end, frame;
WsViewAnchor view;
} ws_view_layers[WS_VIEW_LAYERS];
static WsViewAnchor ws_bg2d_layer_view(int layer) {
WsViewAnchor v = ws_view_current();
if (!ws_view_enabled() || layer < 0 || layer >= WS_VIEW_LAYERS ||
!(ws_view_independent_mask & (1u << layer))) return v;
uint32_t b = g_bg2d_layer_base + (unsigned)layer * g_bg2d_layer_struct_stride;
if ((int8_t)psx_read_byte(b + 0x52) >= 0) return v;
int sx = (int16_t)psx_read_half(b + 0xa);
int lo = psx_read_byte(b + 0x4d) * 256;
int hi = (psx_read_byte(b + 0x4e) + 1) * 256;
int width = ws_disp_w(), extra = ws_nw_offset();
int frame_width = width + 2 * extra;
/* Keep the foreground's desired origin until it would expose void in
* this slower layer. Shift that layer as a whole; do not stretch tiles. */
if (hi - lo >= frame_width && sx >= lo && sx + width <= hi) {
int origin = extra + v.shift;
int max_origin = sx - lo;
int min_origin = frame_width - (hi - sx);
if (origin > max_origin) origin = max_origin;
if (origin < min_origin) origin = min_origin;
v.shift = origin - extra;
v.left = origin;
v.right = 2 * extra - origin;
} else if (g_bg2d_host_size && hi > lo && hi - lo < frame_width) {
int pad = frame_width - (hi - lo);
v.pad_left = pad / 2;
v.pad_right = pad - v.pad_left;
v.shift = sx - lo + v.pad_left - extra;
v.left = sx > lo ? sx - lo : 0;
v.right = hi > sx + width ? hi - sx - width : 0;
}
return v;
}
void gpu_ws_bg2d_begin_view_layer(unsigned layer, uint32_t packet, unsigned independent_mask) {
if (g_bg2d_host_size) {
ws_view_sample();
if (layer == 0) ++g_bg2d_host_generation;
}
if (!ws_view_enabled() || layer >= WS_VIEW_LAYERS) return;
ws_view_independent_mask = independent_mask;
ws_view_layer = (int)layer;
ws_view_layers[layer].begin = packet & 0x1fffffffu;
ws_view_layers[layer].end = ws_view_layers[layer].begin;
ws_view_layers[layer].frame = (uint32_t)s_frame_count;
}
int gpu_ws_bg2d_get_view(unsigned layer, WsViewAnchor *view) {
if (!view || !ws_native_wide_active() || ws_disp_w() > 384) return 0;
*view = ws_bg2d_layer_view((int)layer);
return ws_disp_w();
}
void gpu_ws_bg2d_end_view_layer(unsigned layer, uint32_t packet) {
if (!ws_view_enabled() || layer >= WS_VIEW_LAYERS) return;
ws_view_layers[layer].end = packet & 0x1fffffffu;
ws_view_layers[layer].view = ws_bg2d_layer_view((int)layer);
ws_view_layer = -1;
}
static WsViewAnchor ws_view_packet(void) {
uint32_t packet = ws_bg2d_host_packet();
if (packet) {
WsViewAnchor v = ws_view_current();
uint32_t metadata = psx_read_word(packet + 16u);
v.shift = (psx_read_word(packet + 28u) & ~GPU_WS_BG2D_MIRROR_X) ==
GPU_WS_BG2D_BANK_PACKET_MAGIC ? (int16_t)metadata : (int32_t)metadata;
v.pad_left = (int32_t)psx_read_word(packet + 20u);
v.pad_right = (int32_t)psx_read_word(packet + 24u);
v.left = ws_nw_offset() + v.shift - v.pad_left;
v.right = 2 * ws_nw_offset() - v.left - v.pad_left - v.pad_right;
return v;
}
if (!ws_view_enabled()) return ws_view_current();
uint32_t addr = gp0_cmd_source_addr & 0x1fffffffu;
for (unsigned i = 0; i < WS_VIEW_LAYERS; i++)
if ((uint32_t)s_frame_count - ws_view_layers[i].frame <= 1u &&
addr >= ws_view_layers[i].begin && addr < ws_view_layers[i].end)
return ws_view_layers[i].view;
return ws_view_current();
}
void gpu_ws_bg2d_configure(uint32_t layer_base, uint32_t ring_base,
uint32_t map_size_addr, uint32_t layer_stride_addr,
@@ -1234,20 +1405,26 @@ void gpu_ws_bg2d_set_parent_links(int on) {
}
static int ws_bg2d_left_cols(void) {
if (g_bg2d_host_size) return 0;
if (!ws_native_wide_active()) return 0;
/* Only the ~320 gameplay mode; the engine's 512 hi-res mode (title) draws
* its own 33 columns and centres itself — never double-shift it. */
if (ws_disp_w() > 384) return 0;
int off = ws_nw_offset(); /* per-side reveal in screen px */
int off = ws_bg2d_layer_view(ws_view_layer).left;
if (off <= 0) return 0;
return (off + 15) / 16; /* ceil to whole tile columns */
}
static int ws_bg2d_right_cols(void) {
if (g_bg2d_host_size) return 0;
if (!ws_native_wide_active() || ws_disp_w() > 384) return 0;
return (ws_bg2d_layer_view(ws_view_layer).right + 15) / 16;
}
/* Column count: base + both-side reveal. */
static void mmx6_bg_refill_tick(void); /* defined below (ring-freshness fix) */
int psx_ws_bg2d_cols(int base) {
g_bg2d_native_cols = base;
mmx6_bg_refill_tick();
return base + 2 * ws_bg2d_left_cols();
return base + ws_bg2d_left_cols() + ws_bg2d_right_cols();
}
/* Start tile column: refill before the first column is consumed, then begin LEFT
* earlier in the ring. The count hook retains the same tick for layouts that load
@@ -1270,9 +1447,9 @@ int psx_ws_bg2d_startx(int x) { return x - ws_bg2d_left_cols() * 16; }
* Identity (no extra streaming) at 4:3 / 512 hi-res, so the ring is byte-identical
* there. The 64-col ring has ample slack (visible ~21 cols) for ±LEFT more. */
int psx_ws_bg2d_stream_left(int x) { return x - ws_bg2d_left_cols() * 16; }
int psx_ws_bg2d_stream_right(int x) { return x + ws_bg2d_left_cols() * 16; }
int psx_ws_bg2d_stream_right(int x) { return x + ws_bg2d_right_cols() * 16; }
int psx_ws_bg2d_undercap(int counter, int native_cap) {
int cap = ws_bg2d_left_cols() > 0 ? (int)g_bg2d_packet_cap : native_cap;
int cap = ws_bg2d_left_cols() + ws_bg2d_right_cols() > 0 ? (int)g_bg2d_packet_cap : native_cap;
return counter < cap;
}
@@ -1298,6 +1475,11 @@ void psx_ws_mmx6_bg_stage_init(void) {
if (!ws_clear_reveal || ws_mode != 2 || frame == last_frame) return;
last_frame = frame;
g_mmx6_void_generation++;
/* A host tile arena rebuilds its backbuffer every submission. A scrolling
* effect can initialize a ring every frame (MMX6 rain and museum layers);
* clearing BOTH display bands here erases the already-rendered frontbuffer.
* Its next draw already clears reveal strips, using host_generation. */
if (g_bg2d_host_size) return;
ws_clear_all_reveal_margins();
}
@@ -1453,10 +1635,10 @@ long gpu_ws_mmx6_refill_cols(void) { return g_mmx6_refill_cols; }
* No-op at 4:3 / 512 hi-res (left==0). Idempotent over the native columns the engine
* already streamed correctly, so re-streaming them is harmless. */
void psx_ws_mmx6_bg_refill_all(void) {
if (g_bg2d_host_size) return;
if (!g_mmx6_freshfix) return;
if (!ws_native_wide_active() || ws_disp_w() > 384) return;
int left = ws_bg2d_left_cols();
if (left <= 0) return;
if (ws_nw_offset() <= 0) return;
g_mmx6_refill_cols = 0;
for (uint32_t layer = 0; layer < g_bg2d_layer_count; layer++) {
uint32_t lbase = g_bg2d_layer_base + layer * g_bg2d_layer_struct_stride;
@@ -1470,10 +1652,13 @@ void psx_ws_mmx6_bg_refill_all(void) {
sx += (int16_t)psx_read_half(pbase + 0xa);
sy += (int16_t)psx_read_half(pbase + 0xe);
}
WsViewAnchor layer_view = ws_bg2d_layer_view((int)layer);
int left = (layer_view.left + 15) / 16;
int right = (layer_view.right + 15) / 16;
int sxr = sx;
if (sxr < 0) sxr += 0xf;
int start_col = sxr >> 4;
for (int ci = -left; ci < g_bg2d_native_cols + left; ci++) {
for (int ci = -left; ci < g_bg2d_native_cols + right; ci++) {
int world_x = sx + ci * 16;
if (world_x < 0) {
bg2d_clear_column((int)layer, start_col + ci, sy - 0x10);
@@ -1493,6 +1678,7 @@ static void mmx6_bg_refill_tick(void) {
uint32_t f = (uint32_t)s_frame_count;
if (f == g_mmx6_refill_frame) return;
g_mmx6_refill_frame = f;
ws_view_sample();
psx_ws_mmx6_bg_refill_all();
}
@@ -1861,7 +2047,13 @@ void gpu_ws_get_debug(GpuWsDebug* out) {
out->xden = ws_xden;
out->mode = ws_mode;
out->nw_extra = ws_nw_extra();
WsViewAnchor view = ws_view_current();
out->view_anchor = ws_view_enabled();
out->view_left = view.left; out->view_right = view.right;
out->view_shift = view.shift;
out->view_pad_left = view.pad_left; out->view_pad_right = view.pad_right;
out->cur_frame = s_frame_count;
out->bg2d_generations = g_bg2d_host_generation;
out->last_tag_frame = ws_last_tag_stamp;
out->last_3d_frame = ws_last_3d_stamp;
out->gte_verts = ws_gte_prev_verts;
@@ -2024,6 +2216,18 @@ void gpu_ws_tag_repeat_rect(uint32_t prim, int32_t period) {
period, &guard, (uint32_t)s_frame_count);
}
void gpu_ws_tag_screen_mask_quad(uint32_t prim) {
if (!ws_native_wide_configured() || (prim & 3u) || prim > UINT32_MAX - 4u)
return;
uint32_t words[12], count = 0;
if (!ws_hud_command_words(prim + 4u, words, &count) || count != 5u ||
((words[0] >> 24) & 0xfdu) != 0x28u) return;
WsPrepassPacketGuard guard = ws_prepass_packet_guard(words, count);
ws_hud_anchor_insert(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE,
(prim + 4u) & 0x1ffffcu, 0, &guard,
(uint32_t)s_frame_count);
}
static int ws_nw_explicit_hud_delta(int32_t *out_delta) {
if (out_delta) *out_delta = 0;
if (!ws_native_wide_active() || gp0_cmd_source_addr == 0xFFFFFFFFu)
@@ -2458,6 +2662,7 @@ static int ws_nw_left_hud_packet(void) {
}
static int32_t ws_nw_hud_shift(int32_t x, int32_t w) {
if (!ws_native_wide_active()) return 0;
if (g_bg2d_host_size && ws_tagged_world_primitive()) return 0;
int32_t off = ws_nw_offset();
if (off <= 0) return 0;
int hud_edge = ws_tagged_hud_edge();
@@ -2807,7 +3012,10 @@ void gpu_vertical_split_debug(int *active, int *left_age, int *right_age) {
* when native-wide is active and that buffer is a known display buffer; else
* disable mirroring for this draw. Called when the draw env changes. */
static void ws_nw_sync_target(void) {
if (!ws_native_wide_active()) { gr_wide_disable_target(); return; }
if (!ws_native_wide_active()) {
gr_wide_set_view(0, 0, 0, 0);
gr_wide_disable_target(); return;
}
gr_wide_configure(ws_nw_present_width(), ws_nw_offset());
int local_base = 0;
if (ws_local_viewport_draw_target(&local_base)) {
@@ -2943,6 +3151,7 @@ static void gpu_reset_state(int clear_vram) {
gp0_cmd_source_addr = 0xFFFFFFFFu;
ws_hud_anchor_clear(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE);
ws_hud_anchor_clear(ws_reveal_clear_tags, WS_HUD_ANCHOR_TABLE_SIZE);
ws_hud_anchor_clear(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE);
ws_repeat_rect_tag_clear(ws_repeat_rect_tags);
polyline_color = 0;
polyline_prev_x = polyline_prev_y = 0;
@@ -3617,7 +3826,9 @@ static int32_t sign_extend(uint32_t val, int bits) {
/* Parse a vertex position word: signed 11-bit X and Y */
static void parse_vertex(uint32_t word, int32_t* x, int32_t* y) {
*x = sign_extend(word & 0x7FFu, 11);
int extended = ws_bg2d_host_packet() || (g_bg2d_host_size &&
ws_native_wide_active() && ws_tagged_world_primitive());
*x = extended ? (int16_t)word : sign_extend(word & 0x7FFu, 11);
*y = sign_extend((word >> 16) & 0x7FFu, 11);
}
@@ -3794,8 +4005,10 @@ static inline void draw_area_host_x_bounds(int32_t *left, int32_t *right) {
/* Use the union of the guest draw area and the widescreen mirror.
* Wider staging areas may share the framebuffer X origin; clamping them
* to the mirror width would drop valid canonical VRAM writes. */
int32_t wide_left = (int32_t)draw_area_left - margin;
int32_t wide_right = (int32_t)draw_area_left + (int32_t)ws_disp_w() + margin - 1;
WsViewAnchor v = ws_view_packet();
int32_t wide_left = (int32_t)draw_area_left - margin - (v.shift > 0 ? v.shift : 0);
int32_t wide_right = (int32_t)draw_area_left + (int32_t)ws_disp_w() + margin - 1
- (v.shift < 0 ? v.shift : 0);
if (wide_left < *left) *left = wide_left;
if (wide_right > *right) *right = wide_right;
}
@@ -3946,6 +4159,21 @@ static void gp0_exec_mono_quad(void) {
int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3);
if (rej_a && rej_b) return;
/* Each added band is outside the original screen and executes in the
* mask's own OT position/blend mode. Canonical VRAM clips it away. */
if (ws_native_wide_active() && gp0_cmd_source_addr != UINT32_MAX &&
ws_hud_anchor_lookup(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE,
gp0_cmd_source_addr & 0x1ffffcu, gp0_cmd_buf, 5u,
(uint32_t)s_frame_count, NULL)) {
WsScreenMaskBand band;
if (ws_screen_mask_band(vx, vy, ws_disp_w(), ws_disp_h(),
draw_area_wide_x_margin(), &band)) {
gr_set_semi_transparency(semi_trans, (int)semi_transparency);
gr_draw_flat_rect(band.x + draw_offset_x, band.y + draw_offset_y,
band.w, band.h, color);
}
}
/* Full-screen filters are commonly encoded as an axis-aligned quad. Drawing
* a semi-transparent quad as two independent triangles blends their shared
* diagonal twice; render the equivalent rectangle once to avoid that seam.
@@ -4614,8 +4842,13 @@ static void gp0_exec_textured_16x16(void) {
/* Never suppress MMX6 BG packets at a guessed finite-map boundary. The
* classifier cannot distinguish an authored layer entering the reveal from
* a stale ring slot; suppressing here caused the stage-start black flicker. */
int ws_w = ws_sprt_fixed_transform(&x0, y0, 16);
x0 += ws_nw_hud_shift(x0, 16); /* native-wide HUD corner re-anchor (no-op else) */
uint32_t host_packet = ws_bg2d_host_packet();
int host_tile = host_packet != 0;
/* A pending full-width OT can outlive a resize back to 4:3. Keep its
* central tiles; the normal draw area clips the extra columns. Dropping
* every host packet here would blank that transition frame. */
int ws_w = host_tile ? 0 : ws_sprt_fixed_transform(&x0, y0, 16);
if (!host_tile) x0 += ws_nw_hud_shift(x0, 16);
x0 += draw_offset_x; y0 += draw_offset_y;
int u0 = gp0_cmd_buf[2] & 0xFF;
int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF;
@@ -4627,12 +4860,21 @@ static void gp0_exec_textured_16x16(void) {
int dw = (ws_w && ws_w != 16) ? ws_w : 16;
if (draw_area_out_rect(x0, y0, dw, 16)) return;
}
uint16_t bank = host_tile &&
(psx_read_word(host_packet + 28u) & ~GPU_WS_BG2D_MIRROR_X) == GPU_WS_BG2D_BANK_PACKET_MAGIC ?
(uint16_t)(psx_read_word(host_packet + 16u) >> 16) : 0;
if (bank && (gr_backend() != GR_BACKEND_OPENGL ||
!gl_renderer_select_texture_bank_live_clut(bank))) return;
setup_textured_draw(color24, semi_trans, raw_texture);
if (ws_w && ws_w != 16)
if (host_tile && (psx_read_word(host_packet + 28u) & GPU_WS_BG2D_MIRROR_X))
gr_draw_textured_rect_scaled(x0, y0, 16, 16, u0 + 15, v0, u0 - 1, v0 + 16,
clut_x, clut_y, current_texpage());
else if (ws_w && ws_w != 16)
gr_draw_textured_rect_scaled(x0, y0, ws_w, 16, u0, v0, u0 + 16, v0 + 16,
clut_x, clut_y, current_texpage());
else
gr_draw_textured_rect(x0, y0, 16, 16, u0, v0, clut_x, clut_y, current_texpage());
if (bank) (void)gl_renderer_select_texture_bank(0);
}
/* ---- GP0 command execution ---- */
@@ -5281,15 +5523,14 @@ static uint32_t gp0_opcode_count[256];
uint32_t gpu_get_opcode_count(uint8_t op) { return gp0_opcode_count[op]; }
/* ---- Per-frame GP0 command ring (always-on, queried via debug server) ---- */
/* We record every GP0 command (header + up to 6 payload words) with the
* frame number it was issued in. Per CLAUDE.md ring-buffer rule: capture
* is continuous and observers query a window of interest later, not arm-
* then-record. ~34 MB at 1M entries. Polyline / long commands get the
* first 6 payload words; that's enough for the header + first vertex pair
* + first uv/color word for diagnosing per-primitive state. */
/* ---- Per-frame GP0 command ring (debug tools only) -----------------------
* We record every GP0 command with the frame number it was issued in. Polyline
* / long commands get the current public word cap, enough for diagnosing
* per-primitive state. Production builds leave the public accessors linkable
* but report an unavailable/empty capture. */
extern uint64_t s_frame_count; /* defined in debug_server.c */
#ifndef PSX_NO_DEBUG_TOOLS
extern uint32_t g_debug_last_store_pc; /* defined in debug_server.c */
extern uint32_t g_debug_current_func_addr; /* defined in debug_server.c */
uint32_t debug_guest_ra(void); /* accessor in debug_server.c (guest $ra) */
@@ -5366,10 +5607,6 @@ uint64_t gpu_gp0_ring_total(void) { return gp0_ring_seq; }
uint32_t gpu_gp0_ring_capacity(void) { return GP0_RING_CAP; }
uint32_t gpu_gp0_ring_max_words(void){ return GPU_GP0_RING_MAX_WORDS; }
void gpu_set_gp0_source(uint32_t addr) {
gp0_next_source_addr = addr;
}
/* Fill `out[0..max_out-1]` with entries from the requested frame; returns
* count. Walks from oldest in-buffer to newest so iteration order matches
* draw order within a frame. */
@@ -5400,6 +5637,30 @@ void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest) {
if (out_oldest) *out_oldest = gp0_ring[start].frame;
if (out_newest) *out_newest = gp0_ring[newest_idx].frame;
}
#else
static void gp0_ring_record(const uint32_t *words, int n) {
(void)words;
(void)n;
}
uint64_t gpu_gp0_ring_total(void) { return 0; }
uint32_t gpu_gp0_ring_capacity(void) { return 0; }
uint32_t gpu_gp0_ring_max_words(void) { return GPU_GP0_RING_MAX_WORDS; }
int gpu_gp0_ring_dump_frame(uint32_t frame, GpuGp0RingEntry *out, int max_out) {
(void)frame;
(void)out;
(void)max_out;
return 0;
}
void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest) {
if (out_oldest) *out_oldest = 0;
if (out_newest) *out_newest = 0;
}
#endif
void gpu_set_gp0_source(uint32_t addr) {
gp0_next_source_addr = addr;
}
/* ---- Draw census ring (ALWAYS-ON) -----------------------------------------
* Every drawn primitive (GP0 opcode 0x20-0x7F) records: frame, the prim's DMA
@@ -5535,6 +5796,34 @@ static void gp0_execute_command(void) {
gp0_ring_record(gp0_cmd_buf, gp0_words_needed);
extern void ws_bg_phase_note(uint32_t op);
ws_bg_phase_note(opcode); /* native-wide 2D-backdrop stretch: background-phase latch */
if (ws_view_camera_addr || g_bg2d_host_size) {
WsViewAnchor v = ws_view_current();
/* Draw env packets select the back-buffer band after linked-list
* begin. Clear narrow-room padding at its first actual draw. */
static uint32_t clear_frame[512];
static uint32_t clear_generation[512];
if (opcode >= 0x20 && opcode <= 0x7F &&
(ws_view_enabled() || (g_bg2d_host_size && ws_native_wide_active() && ws_disp_w() <= 384)) &&
(g_bg2d_host_size || v.pad_left || v.pad_right) && draw_area_top < 512 &&
(g_bg2d_host_size
? clear_generation[draw_area_top] != g_bg2d_host_generation
: clear_frame[draw_area_top] != (uint32_t)s_frame_count + 1u) &&
ws_is_fb_base(draw_area_left)) {
clear_frame[draw_area_top] = (uint32_t)s_frame_count + 1u;
clear_generation[draw_area_top] = g_bg2d_host_generation;
gr_wide_clear_margins((int)draw_area_left, (int)draw_area_top,
(int)draw_area_bottom - (int)draw_area_top + 1, 0, 3);
}
v = ws_view_packet();
int32_t hud_delta;
/* Existing HUD placement stays fixed in the physical frame. Only the
* wide world mirror receives the camera-edge origin and room clip. */
if (ws_nw_left_hud_packet() ||
(!(g_bg2d_host_size && ws_tagged_world_primitive()) &&
ws_nw_explicit_hud_delta(&hud_delta)))
v.shift = v.pad_left = v.pad_right = 0;
gr_wide_set_view(ws_view_enabled(), v.shift, v.pad_left, v.pad_right);
}
/* Draw-census: capture every drawing primitive's first vertex + camera. */
if (opcode >= 0x20 && opcode <= 0x7F) {
@@ -6292,6 +6581,7 @@ int gpu_snapshot_read(const uint8_t *p, uint32_t len) {
ws_scene_hold_reset(&s_ws_scene_hold);
ws_hud_anchor_clear(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE);
ws_hud_anchor_clear(ws_reveal_clear_tags, WS_HUD_ANCHOR_TABLE_SIZE);
ws_hud_anchor_clear(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE);
ws_repeat_rect_tag_clear(ws_repeat_rect_tags);
/* Sync renderer clip/scissor to restored GP0(E3/E4); vars alone leave GL
* on a stale draw area after savestate load. */
+49 -9
View File
@@ -512,6 +512,7 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh);
static int s_raster_ok = 0; /* full GPU pipeline available */
static GLuint s_bank_tex[65536];
static GLuint s_selected_bank_tex;
static int s_selected_bank_live_clut, s_tb_bank_live_clut;
/* Authoritative VRAM: hr color texture + stencil (mask bit) FBO. */
static GLuint s_hr_tex = 0, s_hr_fbo = 0, s_hr_rb = 0;
@@ -555,6 +556,7 @@ static float s_pq[3];
/* TEX program uniforms. */
static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -1;
static GLint s_uPalette = -1;
static GLint s_uRaw = -1, s_uSemipass = -1, s_uSemimode = -1;
static GLint s_uTwin = -1, s_uMaskset = -1, s_uFilter = -1;
static GLint s_uLimits = -1;
@@ -693,7 +695,8 @@ static int s_wide_suppress = 0;
/* X-translation (native px) from canonical VRAM space into the active wide
* surface: local_x = vram_x - base_x + OFFSET. Same as SW wide_dx(). */
static inline int wide_dx(void) { return g_wide_off - g_wide_cur_base; }
static int view_enabled, view_shift, view_pad_left, view_pad_right;
static inline int wide_dx(void) { return g_wide_off + view_shift - g_wide_cur_base; }
/* ---- dirty-rect helpers ------------------------------------------------- */
static void rect_clear(DirtyRect *r) { r->set = 0; }
@@ -1315,6 +1318,7 @@ static const char *TEX_FS =
"flat in ivec4 v_limits; /* prim uv sampling bounds (inclusive, post-wrap) */\n"
"flat in int v_semi; /* GP0 command has semi-transparency enabled */\n"
"uniform usampler2D u_vram;\n"
"uniform usampler2D u_palette;\n"
"uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n"
"uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n"
"uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n"
@@ -1326,6 +1330,11 @@ static const char *TEX_FS =
" if (any(greaterThanEqual(p, textureSize(u_vram, 0)))) return 0;\n"
" return int(texelFetch(u_vram, p, 0).r);\n"
"}\n"
"int palette_at(int x, int y){\n"
" ivec2 p = ivec2(x & 1023, y & 511);\n"
" if (any(greaterThanEqual(p, textureSize(u_palette, 0)))) return 0;\n"
" return int(texelFetch(u_palette, p, 0).r);\n"
"}\n"
"int fetch_texel(int u, int v){\n"
" u &= 255; v &= 255;\n"
" if ((u_twin.x | u_twin.y) != 0) {\n"
@@ -1337,10 +1346,10 @@ static const char *TEX_FS =
" }\n"
" if (v_depth == 0) {\n"
" int px = vram_at(v_tpage.x + (u >> 2), v_tpage.y + v);\n"
" return vram_at(v_clut.x + ((px >> ((u & 3) * 4)) & 0xF), v_clut.y);\n"
" return palette_at(v_clut.x + ((px >> ((u & 3) * 4)) & 0xF), v_clut.y);\n"
" } else if (v_depth == 1) {\n"
" int px = vram_at(v_tpage.x + (u >> 1), v_tpage.y + v);\n"
" return vram_at(v_clut.x + ((px >> ((u & 1) * 8)) & 0xFF), v_clut.y);\n"
" return palette_at(v_clut.x + ((px >> ((u & 1) * 8)) & 0xFF), v_clut.y);\n"
" }\n"
" return vram_at(v_tpage.x + u, v_tpage.y + v);\n"
"}\n"
@@ -2197,7 +2206,7 @@ static void flush_pack_if_sampling(int tpage_x, int tpage_y, int depth,
int clut_x, int clut_y) {
if (!s_pack_dirty.set) return;
int page_w = depth == 0 ? 64 : depth == 1 ? 128 : 256; /* VRAM columns */
if (rect_intersects(&s_pack_dirty, tpage_x, tpage_y,
if (tpage_x >= 0 && rect_intersects(&s_pack_dirty, tpage_x, tpage_y,
tpage_x + page_w - 1, tpage_y + 255)) {
flush_flat_batch();
flush_tex_batch(); /* queued draws are part of s_pack_dirty — realise them before packing */
@@ -2327,7 +2336,9 @@ static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) {
if (sy < 0) { sh += sy; sy = 0; }
if (sy + sh > VRAM_H) sh = VRAM_H - sy;
if (sh < 0) sh = 0;
glScissor(0, sy * s_out_scale, g_wide_w * s_out_scale, sh * s_out_scale);
glScissor(view_pad_left * s_out_scale, sy * s_out_scale,
(g_wide_w - view_pad_left - view_pad_right) * s_out_scale,
sh * s_out_scale);
}
p_glUniform1f(uXoff, (float)dx);
p_glUniform1f(uXhalf, (float)g_wide_w / 2.0f);
@@ -2389,13 +2400,13 @@ static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h
/* True if [lo,hi] (canonical draw-x) lies strictly inside the 4:3 frame, so the
* prim adds nothing to either reveal margin and its mirror can be skipped. */
static int mirror_x_center_only(int lo, int hi) {
if (!s_wide_fast) return 0;
if (!s_wide_fast || view_enabled) return 0;
int base = g_wide_cur_base, native_w = g_wide_w - 2 * g_wide_off;
if (native_w <= 0) return 0;
return (lo >= base) && (hi < base + native_w);
}
static int mirror_geo_center_only(const int *xs, int n) {
if (!s_wide_fast) return 0;
if (!s_wide_fast || view_enabled) return 0;
int lo = xs[0], hi = xs[0];
for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; }
return mirror_x_center_only(lo, hi);
@@ -2844,6 +2855,10 @@ static void flush_tex_batch(void) {
p_glActiveTexture(PSXGL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, s_tb_bank_tex ? s_tb_bank_tex : s_raw_tex);
p_glUniform1i(s_uVram, 0);
p_glActiveTexture(PSXGL_TEXTURE0 + 1);
glBindTexture(GL_TEXTURE_2D, s_tb_bank_tex && !s_tb_bank_live_clut ? s_tb_bank_tex : s_raw_tex);
p_glUniform1i(s_uPalette, 1);
p_glActiveTexture(PSXGL_TEXTURE0);
p_glUniform4i(s_uTwin, s_tb_twin[0], s_tb_twin[1], s_tb_twin[2], s_tb_twin[3]);
p_glUniform1i(s_uMaskset, s_tb_mask);
p_glUniform1i(s_uFilter, s_tb_filter);
@@ -3239,6 +3254,8 @@ static void gpu_textured_triangle(const int *xs, const int *ys,
flush_cpu_upload(); /* if a CPU->VRAM upload is pending it flushes the batch first */
if (!s_selected_bank_tex)
flush_pack_if_sampling(base_x, base_y, depth, clut_x, clut_y);
else if (s_selected_bank_live_clut)
flush_pack_if_sampling(-1, 0, depth, clut_x, clut_y);
mark_prim_dirty(xs, ys, 3, 1 /* textured */);
/* Append to the textured batch. Flush first if this prim's blend/mask/twin/
@@ -3278,7 +3295,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys,
!mod_texture_bank_batchable(s_selected_bank_tex != 0, s_mask_check, semi);
int reason = -1;
if (s_tb_n > 0) {
if (s_tb_bank_tex != s_selected_bank_tex) reason = 0;
if (s_tb_bank_tex != s_selected_bank_tex || s_tb_bank_live_clut != s_selected_bank_live_clut) reason = 0;
else if (isolate) reason = 0;
else if (batch_semi != s_tb_semi) reason = 1;
else if (s_mask_set != s_tb_mask) reason = 2;
@@ -3295,6 +3312,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys,
if (s_tb_n == 0) { /* opening a batch: capture its keyed state */
s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = s_tex_filter; s_tb_gate = gate;
s_tb_bank_tex = s_selected_bank_tex;
s_tb_bank_live_clut = s_selected_bank_live_clut;
s_tb_twin[0] = twx; s_tb_twin[1] = twy; s_tb_twin[2] = tox; s_tb_twin[3] = toy;
}
float *vp = &s_tb[s_tb_n * TEXV];
@@ -4199,6 +4217,7 @@ static int init_gpu_raster(void) {
}
s_uVram = p_glGetUniformLocation(s_tex_prog, "u_vram");
s_uPalette = p_glGetUniformLocation(s_tex_prog, "u_palette");
s_uTpage = p_glGetUniformLocation(s_tex_prog, "u_tpage");
s_uClut = p_glGetUniformLocation(s_tex_prog, "u_clut");
s_uDepth = p_glGetUniformLocation(s_tex_prog, "u_depth");
@@ -4441,6 +4460,7 @@ int gl_renderer_select_texture_bank(uint16_t id) {
uint32_t width, height;
const uint16_t* pixels;
GLint alignment, row_length;
s_selected_bank_live_clut = 0;
if (!id) { s_selected_bank_tex = 0; return 1; }
if (!gl_renderer_texture_banks_supported()) return 0;
if (!s_bank_tex[id]) {
@@ -4470,6 +4490,12 @@ int gl_renderer_select_texture_bank(uint16_t id) {
return 1;
}
int gl_renderer_select_texture_bank_live_clut(uint16_t id) {
if (!gl_renderer_select_texture_bank(id)) return 0;
s_selected_bank_live_clut = id != 0;
return 1;
}
int gl_renderer_init_context(SDL_Window *win) {
s_win = win;
s_present_w = 0;
@@ -4583,6 +4609,7 @@ void gl_renderer_shutdown(void) {
}
memset(s_bank_tex, 0, sizeof s_bank_tex);
s_selected_bank_tex = s_tb_bank_tex = 0;
s_selected_bank_live_clut = s_tb_bank_live_clut = 0;
if (s_ctx) {
ensure_cpu();
SDL_GL_DeleteContext(s_ctx); s_ctx = NULL;
@@ -4968,6 +4995,17 @@ static GLuint wide_fbo_for(int base_x) {
/* Enable native-wide with a wide width + centering offset (native px), or
* disable (wide_w <= 0). Re-allocates if the width changed. Mirrors
* sw_wide_configure. */
static void glb_wide_set_view(int enabled, int shift, int pad_left, int pad_right) {
if (!enabled) shift = pad_left = pad_right = 0;
if (view_enabled == enabled && view_shift == shift &&
view_pad_left == pad_left && view_pad_right == pad_right) return;
flush_flat_batch(); flush_tex_batch();
view_enabled = enabled;
view_shift = shift;
view_pad_left = pad_left;
view_pad_right = pad_right;
}
static void glb_wide_configure(int wide_w, int offset) {
if (!s_raster_ok) return;
double t0 = cw_ms(); s_cw_wide_cfgs++;
@@ -5099,6 +5137,7 @@ static int glb_render_wide_display(uint32_t *out, int pitch, int base_x,
/* Fold any pending CPU->VRAM uploads into the canonical FBO first (uploads
* are never mirrored to wide, but draws after them are; keep op order) and
* make sure all wide-FBO draws have completed before the readback. */
flush_flat_batch();
flush_tex_batch();
flush_cpu_upload();
hiw_flush_queue(); /* windowed: queued wide mirrors */
@@ -6864,7 +6903,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear,
* x-translated by the reveal offset. No-op when s_wide_fast is off (then the
* mirror drew the full surface, as before). Shared by both present paths. */
static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) {
if (!s_wide_fast || g_wide_w <= 0) return;
if (!s_wide_fast || view_enabled || g_wide_w <= 0) return;
int native_w = g_wide_w - 2 * g_wide_off;
if (native_w <= 0) return;
int S = s_out_scale;
@@ -7016,6 +7055,7 @@ static const GpuRenderBackend GL_BACKEND = {
.set_draw_area = glb_set_draw_area, .get_draw_area = glb_get_draw_area,
.set_draw_offset = glb_set_draw_offset,
.wide_configure = glb_wide_configure,
.wide_set_view = glb_wide_set_view,
.wide_set_target = glb_wide_set_target,
.wide_disable_target = glb_wide_disable_target,
.wide_clear = glb_wide_clear,
+4
View File
@@ -48,6 +48,7 @@ static const GpuRenderBackend SW_BACKEND = {
.get_draw_area = sw_get_draw_area,
.set_draw_offset = sw_set_draw_offset,
.wide_configure = sw_wide_configure,
.wide_set_view = sw_wide_set_view,
.wide_set_target = sw_wide_set_target,
.wide_disable_target = sw_wide_disable_target,
.wide_clear = sw_wide_clear,
@@ -167,6 +168,9 @@ int gr_wide_supported(void) { return g_b->render_wide_display != 0; }
void gr_wide_configure(int wide_w, int offset) {
if (g_b->wide_configure) g_b->wide_configure(wide_w, offset);
}
void gr_wide_set_view(int enabled, int shift, int pad_left, int pad_right) {
if (g_b->wide_set_view) g_b->wide_set_view(enabled, shift, pad_left, pad_right);
}
void gr_wide_set_target(int base_x) {
if (g_b->wide_set_target) g_b->wide_set_target(base_x);
}
+15 -3
View File
@@ -163,19 +163,21 @@ static inline RTarget rt_hires(void) {
* into the OTHER buffer's band in the shared wide surface and corrupt it
* (top/bottom edge flicker as the buffers flip). Canonical VRAM never showed
* this because its draw-area clip already confines it. Scale matches SSAA. */
static int view_enabled, view_shift, view_pad_left, view_pad_right;
static inline RTarget rt_wide(void) {
int s = g_scale;
RTarget t;
t.buf = g_wide_cur;
t.w = g_wide_w * s; t.h = VRAM_HEIGHT * s; t.s = s;
t.cx1 = 0; t.cy1 = g_clip_y1 * s;
t.cx2 = g_wide_w * s - 1; t.cy2 = g_clip_y2 * s + (s - 1);
t.cx1 = view_pad_left * s; t.cy1 = g_clip_y1 * s;
t.cx2 = (g_wide_w - view_pad_right) * s - 1;
t.cy2 = g_clip_y2 * s + (s - 1);
return t;
}
/* X-translation (native px) from canonical VRAM space into the active wide
* surface: local_x = vram_x - base_x + OFFSET. */
static inline int wide_dx(void) { return g_wide_off - g_wide_cur_base; }
static inline int wide_dx(void) { return g_wide_off + view_shift - g_wide_cur_base; }
/* Native-wide 2D-backdrop X-stretch (SW renderer), mirroring the GL
* wide_set_bd_scale exactly. The far 2D backdrop (sprite-tagged tile grid: sky
@@ -1768,6 +1770,16 @@ static uint16_t *wide_surf_for(int base_x) {
/* Enable native-wide with a wide width + centering offset (native px), or
* disable (wide_w <= 0). Re-allocates if the width changed. */
void sw_wide_set_view(int enabled, int shift, int pad_left, int pad_right) {
if (!enabled) shift = pad_left = pad_right = 0;
if (view_enabled == enabled && view_shift == shift &&
view_pad_left == pad_left && view_pad_right == pad_right) return;
view_enabled = enabled;
view_shift = shift;
view_pad_left = pad_left;
view_pad_right = pad_right;
}
void sw_wide_configure(int wide_w, int offset) {
if (wide_w <= 0) { wide_free_all(); g_wide_w = 0; g_wide_off = 0; return; }
if (wide_w != g_wide_w) wide_free_all();
+16 -2
View File
@@ -2781,7 +2781,8 @@ static void set_vp_scissor(VkCommandBuffer cb) {
/* ---- native-wide compositor helpers ------------------------------------- */
/* X-translation (native px) from canonical VRAM space into the active wide
* surface: local_x = vram_x - base_x + OFFSET. Same as GL/SW wide_dx(). */
static int wide_dx(void) { return s_wide_offset - s_wide_cur_base; }
static int view_enabled, view_shift, view_pad_left, view_pad_right;
static int wide_dx(void) { return s_wide_offset + view_shift - s_wide_cur_base; }
static void wide_free_all(void) {
int any = 0;
@@ -2896,7 +2897,8 @@ static void wide_pass_begin(VkCommandBuffer cb) {
int y1 = s_da_y1 < 0 ? 0 : s_da_y1;
int y2 = s_da_y2 >= VRAM_H ? VRAM_H - 1 : s_da_y2;
int hh = y2 - y1 + 1; if (hh < 0) hh = 0;
VkRect2D sc = { { 0, y1 * S }, { (uint32_t)(s_wide_w * S), (uint32_t)(hh * S) } };
VkRect2D sc = { { view_pad_left * S, y1 * S },
{ (uint32_t)((s_wide_w - view_pad_left - view_pad_right) * S), (uint32_t)(hh * S) } };
p_vkCmdSetScissor(cb, 0, 1, &sc);
}
@@ -3375,6 +3377,17 @@ static void vkb_copy_rect(int sx,int sy,int dx,int dy,int w,int h){
* disable (wide_w <= 0). No-ops when nothing changed (gpu.c calls this on
* every draw-area set via ws_nw_sync_target — Part-A lesson: the wide entry
* points sit inside guest emulation, keep the common path free). */
static void vkb_wide_set_view(int enabled, int shift, int pad_left, int pad_right) {
if (!enabled) shift = pad_left = pad_right = 0;
if (view_enabled == enabled && view_shift == shift &&
view_pad_left == pad_left && view_pad_right == pad_right) return;
flush_tex_batch(); flush_geometry();
view_enabled = enabled;
view_shift = shift;
view_pad_left = pad_left;
view_pad_right = pad_right;
}
static void vkb_wide_configure(int wide_w, int offset) {
if (!s_ready) return;
if (wide_w == s_wide_w && offset == s_wide_offset) return;
@@ -3562,6 +3575,7 @@ static const GpuRenderBackend VK_BACKEND = {
.get_draw_area = vkb_get_draw_area,
.set_draw_offset = vkb_set_draw_offset,
.wide_configure = vkb_wide_configure,
.wide_set_view = vkb_wide_set_view,
.wide_set_target = vkb_wide_set_target,
.wide_disable_target = vkb_wide_disable_target,
.wide_clear = vkb_wide_clear,
+2
View File
@@ -642,6 +642,8 @@ uint32_t psx_irq_resume_context_snapshot_pc(void)
int psx_irq_resume_context_snapshot_safe_at(uint32_t resume_pc)
{
extern int psx_mod_function_entry_active(void);
if (psx_mod_function_entry_active()) return 0;
if (g_cosim_dirty_pump_site == 0)
return 1;
+77 -39
View File
@@ -418,9 +418,11 @@ static void apply_offline_pad_count(int game_players, bool multitap_enabled)
g_offline_pad_count = n;
}
/* ARGB8888 staging buffer. The 576-row maximum preserves the full interlaced
* PAL active canvas. Allocated once the supersampling scale is known. */
* PAL active canvas; wide frames can require more than the canonical width. */
static uint32_t* sdl_pixel_buf = nullptr;
static size_t s_pixel_buf_px = 0; /* sdl_pixel_buf capacity in pixels */
static size_t sdl_pixel_buf_capacity = 0;
static int sdl_texture_width = 0;
static int sdl_texture_height = 0;
typedef void (*ModFrameHook)(void);
@@ -1421,6 +1423,17 @@ static int g_video_aspect_den = 3;
static bool g_ws_adaptive_view = false;
static int g_ws_adaptive_max_num = 16;
static int g_ws_adaptive_max_den = 9;
static bool ensure_sdl_pixel_buf_capacity(size_t pixels) {
if (pixels <= sdl_pixel_buf_capacity) return true;
void* next = std::realloc(sdl_pixel_buf, pixels * sizeof(uint32_t));
if (!next) {
std::fprintf(stderr, "psxrecomp: failed to allocate %zu present pixels\n", pixels);
return false;
}
sdl_pixel_buf = (uint32_t*)next;
sdl_pixel_buf_capacity = pixels;
return true;
}
/* game.toml [netplay] local_viewport = "vertical_split": during real netplay,
* present only this peer's native split-screen half and stretch it to the
* window. Presentation-only; the guest still renders the original framebuffer. */
@@ -2108,6 +2121,27 @@ static int netplay_gl_dual_quality(void) {
}
#ifndef PSX_SDL_NO_RENDER
static int ensure_sdl_present_texture(int min_width, int min_height) {
if (sdl_texture && min_width <= sdl_texture_width &&
min_height <= sdl_texture_height) return 0;
const int width = std::max(min_width, sdl_texture_width);
const int height = std::max(min_height, sdl_texture_height);
SDL_Texture* next = SDL_CreateTexture(sdl_renderer,
SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, width, height);
if (!next) {
std::fprintf(stderr, "psxrecomp: SDL_CreateTexture %dx%d failed: %s\n",
width, height, SDL_GetError());
return -1;
}
SDL_SetTextureScaleMode(next,
g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest);
if (sdl_texture) SDL_DestroyTexture(sdl_texture);
sdl_texture = next;
sdl_texture_width = width;
sdl_texture_height = height;
return 0;
}
/* Create SDL_Renderer + streaming texture for software present. Used when
* netplay runs without a live GL context (software renderer selected, or GL
* init failed). CPU-auth + OpenGL present does not need this. */
@@ -2139,28 +2173,21 @@ static int ensure_sw_sdl_present(void) {
}
if (!sdl_texture) {
const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale;
sdl_texture = SDL_CreateTexture(
sdl_renderer,
SDL_PIXELFORMAT_ARGB8888,
SDL_TEXTUREACCESS_STREAMING,
640 * tex_scale,
(int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale);
if (!sdl_texture) {
if (ensure_sdl_present_texture(
640 * tex_scale,
(int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale) != 0) {
std::fprintf(stderr,
"psxrecomp: netplay SW present: SDL_CreateTexture failed: %s\n",
SDL_GetError());
return -1;
}
SDL_SetTextureScaleMode(sdl_texture,
g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest);
}
if (!sdl_pixel_buf) {
int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale;
if (tex_scale > SW_MAX_INTERNAL_SCALE) tex_scale = SW_MAX_INTERNAL_SCALE;
s_pixel_buf_px = (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT *
tex_scale;
sdl_pixel_buf = (uint32_t*)std::malloc(s_pixel_buf_px * sizeof(uint32_t));
if (!sdl_pixel_buf) {
if (!ensure_sdl_pixel_buf_capacity(
(size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT *
tex_scale)) {
std::fprintf(stderr, "psxrecomp: netplay SW present: staging alloc failed\n");
return -1;
}
@@ -3454,9 +3481,11 @@ static void teardown_game_session_keep_lobby(void) {
g_gl_active = false;
}
if (sdl_texture) { SDL_DestroyTexture(sdl_texture); sdl_texture = nullptr; }
sdl_texture_width = sdl_texture_height = 0;
if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; }
if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; }
if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; s_pixel_buf_px = 0; }
if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; }
sdl_pixel_buf_capacity = 0;
/* Rematch must re-run force_sw (and prefer CPU-auth GL present again). */
s_netplay_sw_gpu_locked = 0;
s_netplay_gl_present = 0;
@@ -7292,6 +7321,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
(turbo_loads_active || fmv_skip_active) ? 1 : 0;
if (g_headless) {
/* Headless skips the presenter below, but still needs the same
* game-entry widescreen activation for its rendered frame dumps. */
if (!g_ws_engaged && fntrace_is_game_started()) {
g_ws_engaged = true;
g_ws_projection_mode = -1;
}
refresh_widescreen_projection();
ep.skip_pace = 1;
return ep;
}
@@ -7619,6 +7655,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
: (w + (uint32_t)ws_nw_extra());
}
/* The software wide readback can exceed the canonical 640px staging
* width, especially on high-resolution and resized adaptive frames. */
const int staging_scale = std::max(1, std::min(
std::max(g_video_scale, gr_scale()), SW_MAX_INTERNAL_SCALE));
const size_t staging_pixels = (size_t)present_w * std::max(h, present_h) *
(size_t)staging_scale * staging_scale;
if (!ensure_sdl_pixel_buf_capacity(staging_pixels)) return ep;
/* Native-wide invariant: canonical (320-wide) content is NEVER
* stretched across the wide window — a game frame that cannot present
* wide (compositor unsupported, or the surface fallback below)
@@ -7726,7 +7770,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
: (int)di.display_x;
/* The staging buffer is sized for at most SW_MAX_INTERNAL_SCALE;
* a GL wide surface at a higher scale falls back to canonical. */
int n = ((size_t)sw * (size_t)h * (size_t)s <= s_pixel_buf_px)
int n = ((size_t)sw * (size_t)h * (size_t)s <= sdl_pixel_buf_capacity)
? gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)),
base_x, (int)di.display_y, (int)h)
: 0;
@@ -7858,6 +7902,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
return ep;
if (!sdl_renderer || !sdl_texture)
return ep;
if (ensure_sdl_present_texture(std::max(1, src_w), std::max(1, src_h)) != 0)
return ep;
SDL_Rect src = { 0, 0, src_w, src_h };
SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf,
(int)(src_w * sizeof(uint32_t)));
@@ -7868,14 +7914,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale;
const int tex_h = (int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale;
if (src_w > 0 && src_h > 0 && src_h < tex_h) {
static uint32_t s_black_pad[640 * 4]; /* covers g_video_scale <= 4 */
const int pad_cap = (int)(sizeof(s_black_pad) / sizeof(s_black_pad[0]));
const int pad_w = (src_w <= pad_cap) ? src_w : pad_cap;
for (int i = 0; i < pad_w; i++)
s_black_pad[i] = 0xFF000000u;
SDL_Rect pad = { 0, src_h, pad_w, 1 };
SDL_UpdateTexture(sdl_texture, &pad, s_black_pad,
(int)(pad_w * sizeof(uint32_t)));
static std::vector<uint32_t> black_pad;
black_pad.assign((size_t)src_w, 0xFF000000u);
SDL_Rect pad = { 0, src_h, src_w, 1 };
SDL_UpdateTexture(sdl_texture, &pad, black_pad.data(),
(int)(src_w * sizeof(uint32_t)));
}
/* FMV (24-bit) frames are authored 4:3 with no GTE squash to compensate
@@ -13453,6 +13496,9 @@ int main(int argc, char** argv) {
gpu_ws_set_slti_lower_cull_sites(
gc.ws_cull_slti_lower_sites.data(),
(int)gc.ws_cull_slti_lower_sites.size());
gpu_ws_set_bias_lower_cull_sites(
gc.ws_cull_bias_lower_sites.data(),
(int)gc.ws_cull_bias_lower_sites.size());
gpu_ws_set_negsub_cull_sites(
gc.ws_cull_negsub_sites.data(), (int)gc.ws_cull_negsub_sites.size());
gpu_ws_set_vxrange_cull_sites(
@@ -16113,10 +16159,9 @@ session_reboot:
* active_scale), so an 8K GL scale never sizes this buffer by S^2. */
int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale;
if (tex_scale > SW_MAX_INTERNAL_SCALE) tex_scale = SW_MAX_INTERNAL_SCALE;
s_pixel_buf_px = (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT *
tex_scale;
sdl_pixel_buf = (uint32_t*)std::malloc(s_pixel_buf_px * sizeof(uint32_t));
if (!sdl_pixel_buf) {
if (!ensure_sdl_pixel_buf_capacity(
(size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT *
tex_scale)) {
std::fprintf(stderr, "failed to allocate %dx staging buffer\n", tex_scale);
return 1;
}
@@ -16124,19 +16169,12 @@ session_reboot:
if (!g_gl_active && !g_vk_active) {
const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale;
sdl_texture = SDL_CreateTexture(
sdl_renderer,
SDL_PIXELFORMAT_ARGB8888,
SDL_TEXTUREACCESS_STREAMING,
640 * tex_scale,
(int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale
);
if (!sdl_texture) {
if (ensure_sdl_present_texture(
640 * tex_scale,
(int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale) != 0) {
std::fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError());
return 1;
}
SDL_SetTextureScaleMode(sdl_texture,
g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest);
}
log_present_cadence();
}
+14
View File
@@ -1786,10 +1786,12 @@ static void isc_store(uint32_t addr, uint32_t val, uint32_t width) {
if ((cache_ctrl & (PSX_BIU_DCACHE_ENABLE | PSX_BIU_LOCK)) ==
PSX_BIU_DCACHE_ENABLE) {
uint32_t off = addr & 0x3FFu;
#ifndef PSX_NO_DEBUG_TOOLS
uint32_t old = 0;
for (uint32_t i = 0; i < width; i++)
old |= (uint32_t)scratchpad[(off + i) & 0x3FFu] << (8u * i);
debug_server_trace_write_check(0x1F800000u + off, old, val, (uint8_t)width);
#endif
for (uint32_t i = 0; i < width; i++)
scratchpad[(off + i) & 0x3FFu] = (uint8_t)(val >> (8u * i));
}
@@ -1922,7 +1924,9 @@ static void psx_write_word_raw(uint32_t addr, uint32_t val) {
}
}
if (phys == D44_PHYS) d44_note(phys, read_ram_word(phys), val);
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_trace_write_check(phys, read_ram_word(phys), val, 4);
#endif
parity_trace_note_write(phys, 4, effective_store_pc());
card_data_writes_check(phys, val, 4);
dirty_ram_mark_kernel_write(phys);
@@ -1955,12 +1959,14 @@ static void psx_write_word_raw(uint32_t addr, uint32_t val) {
if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return;
if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) {
uint32_t off = phys - 0x1F800000u;
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_trace_write_check(phys,
(uint32_t)scratchpad[off]
| ((uint32_t)scratchpad[off + 1] << 8)
| ((uint32_t)scratchpad[off + 2] << 16)
| ((uint32_t)scratchpad[off + 3] << 24),
val, 4);
#endif
scratchpad[off] = (uint8_t)(val);
scratchpad[off + 1] = (uint8_t)(val >> 8);
scratchpad[off + 2] = (uint8_t)(val >> 16);
@@ -2051,7 +2057,9 @@ static void psx_write_half_raw(uint32_t addr, uint16_t val) {
uint32_t phys = psx_phys_addr_store(addr);
if (phys < RAM_SIZE) {
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_trace_write_check(phys, (uint32_t)read_ram_half(phys), (uint32_t)val, 2);
#endif
parity_trace_note_write(phys, 2, effective_store_pc());
card_data_writes_check(phys, (uint32_t)val, 2);
dirty_ram_mark_kernel_write(phys);
@@ -2083,9 +2091,11 @@ static void psx_write_half_raw(uint32_t addr, uint16_t val) {
if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return;
if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) {
uint32_t off = phys - 0x1F800000u;
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_trace_write_check(phys,
(uint32_t)scratchpad[off] | ((uint32_t)scratchpad[off + 1] << 8),
(uint32_t)val, 2);
#endif
scratchpad[off] = (uint8_t)(val);
scratchpad[off + 1] = (uint8_t)(val >> 8);
return;
@@ -2410,7 +2420,9 @@ static void psx_write_byte_raw(uint32_t addr, uint8_t val) {
uint32_t phys = psx_phys_addr_store(addr);
if (phys < RAM_SIZE) {
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_trace_write_check(phys, (uint32_t)ram[phys], (uint32_t)val, 1);
#endif
parity_trace_note_write(phys, 1, effective_store_pc());
card_data_writes_check(phys, (uint32_t)val, 1);
dirty_ram_mark_kernel_write(phys);
@@ -2438,8 +2450,10 @@ static void psx_write_byte_raw(uint32_t addr, uint8_t val) {
}
if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return;
if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) {
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_trace_write_check(phys, (uint32_t)scratchpad[phys - 0x1F800000u],
(uint32_t)val, 1);
#endif
scratchpad[phys - 0x1F800000u] = val;
return;
}
+14 -6
View File
@@ -37,7 +37,7 @@ struct RegisteredPlugin {
PSXModVBlankCallback vblank = nullptr;
/* Generated-function entry hooks, keyed by guest address. One id may
* observe several functions. */
std::vector<std::pair<uint32_t, PSXModFunctionEntryCallback>> function_entries;
std::vector<ModFunctionEntryHook> function_entries;
};
std::map<std::string, RegisteredPlugin>& registered_plugins() {
@@ -1459,8 +1459,18 @@ bool mod_register_function_entry_plugin(const std::string& id, uint32_t address,
RegisteredPlugin& plugin = registered_plugins()[id];
/* Code addresses alias across KUSEG/KSEG0/KSEG1; one function is one hook. */
for (const auto& hook : plugin.function_entries)
if (((hook.first ^ address) & 0x1FFFFFFFu) == 0u) return false;
plugin.function_entries.emplace_back(address, callback);
if (((hook.address ^ address) & 0x1FFFFFFFu) == 0u) return false;
plugin.function_entries.push_back({address, callback, nullptr});
return true;
}
bool mod_register_function_filter_plugin(const std::string& id, uint32_t address,
PSXModFunctionFilterCallback callback) {
if (!valid_id(id) || !address || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
for (const auto& hook : plugin.function_entries)
if (((hook.address ^ address) & 0x1FFFFFFFu) == 0u) return false;
plugin.function_entries.push_back({address, nullptr, callback});
return true;
}
@@ -1487,9 +1497,7 @@ std::vector<ModFunctionEntryHook> mod_function_entry_hooks(const std::string& id
std::vector<ModFunctionEntryHook> hooks;
const auto found = registered_plugins().find(id);
if (found == registered_plugins().end()) return hooks;
for (const auto& hook : found->second.function_entries)
hooks.push_back(ModFunctionEntryHook{hook.first, hook.second});
return hooks;
return found->second.function_entries;
}
void mod_clear_plugins_for_tests() {
+27 -4
View File
@@ -1,4 +1,5 @@
#include "mod_runtime.h"
#include "cpu_state.h"
#include "disc_path.h"
#include "iso_reader.h"
@@ -94,6 +95,7 @@ RuntimeMods& state() {
struct ActiveFunctionEntryHook {
uint32_t key = 0;
PSXModFunctionEntryCallback callback = nullptr;
PSXModFunctionFilterCallback filter = nullptr;
const ModResolution::Plugin* plugin = nullptr;
};
@@ -101,6 +103,7 @@ std::vector<ActiveFunctionEntryHook>& active_function_entry_hooks() {
static std::vector<ActiveFunctionEntryHook> value;
return value;
}
unsigned function_entry_depth;
inline uint32_t function_entry_key(uint32_t address) {
return address & 0x1FFFFFFFu;
@@ -117,7 +120,8 @@ void build_function_entry_hooks(const RuntimeMods& s) {
auto& table = active_function_entry_hooks();
for (const ModResolution::Plugin& plugin : s.plan.plugins)
for (const ModFunctionEntryHook& hook : mod_function_entry_hooks(plugin.id))
table.push_back({function_entry_key(hook.address), hook.callback, &plugin});
table.push_back({function_entry_key(hook.address), hook.callback,
hook.filter, &plugin});
/* Stable: hooks sharing an address keep plan (plugin order) order. */
std::stable_sort(table.begin(), table.end(),
[](const ActiveFunctionEntryHook& a,
@@ -1563,9 +1567,16 @@ extern "C" int psx_mod_register_function_entry_plugin(
return mod_register_function_entry_plugin(id, address, callback) ? 1 : 0;
}
extern "C" void psx_mod_function_entry(CPUState* cpu, uint32_t address) {
extern "C" int psx_mod_register_function_filter_plugin(
const char* id, uint32_t address, PSXModFunctionFilterCallback callback) {
using namespace PSXRecompV4;
if (!g_psx_mod_function_entry_hooks || !cpu) return;
if (!id || !address || !callback) return 0;
return mod_register_function_filter_plugin(id, address, callback) ? 1 : 0;
}
extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) {
using namespace PSXRecompV4;
if (!g_psx_mod_function_entry_hooks || !cpu) return 0;
RuntimeMods& s = state();
const auto& table = active_function_entry_hooks();
const uint32_t key = function_entry_key(address);
@@ -1575,9 +1586,21 @@ extern "C" void psx_mod_function_entry(CPUState* cpu, uint32_t address) {
for (; it != table.end() && it->key == key; ++it) {
const ModResolution::Plugin* previous = s.current_plugin;
s.current_plugin = it->plugin;
it->callback(cpu, address);
++function_entry_depth;
if (it->callback) it->callback(cpu, address);
const int handled = it->filter && it->filter(cpu, address);
--function_entry_depth;
s.current_plugin = previous;
if (handled) {
cpu->pc = cpu->gpr[31];
return 1;
}
}
return 0;
}
extern "C" int psx_mod_function_entry_active(void) {
return PSXRecompV4::function_entry_depth != 0;
}
extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector,
+23 -3
View File
@@ -1316,6 +1316,8 @@ typedef struct {
uint8_t load_failed;
int resident;
int capacity_suppressed;
int loaded_check_count;
int loaded_check_result;
char path[768];
} CacheEntry;
static CacheEntry s_cache_idx[CACHE_IDX_CAP];
@@ -1879,6 +1881,8 @@ static int add_posix_cache_file(const PsxOverlayCacheFile *file, void *opaque) {
e->tier = (uint8_t)tier;
e->manifest_ok = 0;
e->load_failed = 0;
e->loaded_check_count = -1;
e->loaded_check_result = 0;
e->resident = cache_path_is_bios_resident(file->path);
e->capacity_suppressed = 0;
snprintf(e->path, sizeof(e->path), "%s", file->path);
@@ -1924,6 +1928,8 @@ static void scan_one_cache_dir(const char *dir, int tier) {
e->tier = (uint8_t)tier;
e->manifest_ok = 0;
e->load_failed = 0;
e->loaded_check_count = -1;
e->loaded_check_result = 0;
e->capacity_suppressed = 0;
snprintf(e->path, sizeof(e->path), "%s", full);
e->resident = cache_path_is_bios_resident(full);
@@ -2591,7 +2597,7 @@ static void init_callbacks(void) {
}
{
extern int32_t psx_ws_screen_x_bound(int32_t vanilla);
extern void psx_mod_function_entry(CPUState *cpu, uint32_t address);
extern int psx_mod_function_entry(CPUState *cpu, uint32_t address);
s_callbacks.ws_screen_x_bound = psx_ws_screen_x_bound;
s_callbacks.mod_function_entry = psx_mod_function_entry;
}
@@ -3196,6 +3202,20 @@ static int dll_already_loaded(const char *path) {
return 0;
}
/* A lazy manifest chain may have hundreds of functions from the same DLL.
* Re-comparing every long Windows path (including locale case folding) for
* each function dominates uncovered CPS continuation dispatches. Loaded paths
* are append-only: cache membership per artifact until that set grows. This
* caches only path membership; live code/manifest validation is unchanged. */
static int cache_entry_already_loaded(int ci) {
CacheEntry *e = &s_cache_idx[ci];
if (e->loaded_check_count != s_nloaded_paths) {
e->loaded_check_result = dll_already_loaded(e->path);
e->loaded_check_count = s_nloaded_paths;
}
return e->loaded_check_result;
}
static void overlay_library_close(OverlayLibraryHandle handle) {
if (!handle) return;
#ifdef _WIN32
@@ -3533,7 +3553,7 @@ static int lazy_is_loadable(int li, uint32_t region_start, uint32_t seg,
s_cache_idx[ci].region_start == region_start) &&
!s_cache_idx[ci].load_failed &&
!s_cache_idx[ci].capacity_suppressed &&
!dll_already_loaded(s_cache_idx[ci].path) &&
!cache_entry_already_loaded(ci) &&
lazy_man_contains(&lm->fn, seg, phys) && lazy_man_matches(lm);
}
@@ -3553,7 +3573,7 @@ static int lazy_load_selected(int li) {
if (ci < 0 || ci >= s_cache_idx_count || ci >= CACHE_IDX_CAP ||
s_cache_idx[ci].load_failed ||
s_cache_idx[ci].capacity_suppressed ||
dll_already_loaded(s_cache_idx[ci].path) ||
cache_entry_already_loaded(ci) ||
s_cache_idx[ci].func_count <= 0) return 0;
/* If proactive warming has not reached this fragment yet, prefer the
* historical synchronous path over running a potentially hot function in
+2
View File
@@ -766,6 +766,8 @@ uint32_t savestate_last_save_pc(void) {
void savestate_poll(CPUState* cpu, uint32_t resume_pc) {
if (s_save_pending < 0 && s_load_pending < 0) return; /* hot path: nothing staged */
extern int psx_mod_function_entry_active(void);
if (psx_mod_function_entry_active()) return;
if (s_save_pending >= 0) {
int slot = s_save_pending;
+2 -1
View File
@@ -187,8 +187,9 @@ uint32_t psx_ws_backdrop_value(uint32_t orig, int end, int cols) {
int32_t psx_ws_depth_bound(int32_t imm) { return imm; }
int32_t psx_ws_player_x_bound(int32_t vanilla) { return vanilla; }
int32_t psx_ws_screen_x_bound(int32_t vanilla) { return vanilla; }
void psx_mod_function_entry(CPUState *cpu, uint32_t address) {
int psx_mod_function_entry(CPUState *cpu, uint32_t address) {
(void)cpu; (void)address;
return 0;
}
int psx_netplay_is_resimulating(void) { return 0; }
int psx_game_text_native_ok(uint32_t address) { (void)address; return 1; }
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Build actual audio_trace.c in debug and PSX_NO_DEBUG_TOOLS modes."""
from __future__ import annotations
import argparse
import pathlib
import shutil
import subprocess
import sys
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[2]
DEFAULT_GCC = pathlib.Path(r"C:\msys64\mingw64\bin\gcc.exe")
DRIVER_C = r"""
#include "audio_trace.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define PCM_RING_FRAMES (1u << 22)
static void fail(const char *msg) {
fprintf(stderr, "FAIL: %s\n", msg);
exit(1);
}
static uint32_t rd32(const unsigned char *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static int16_t rd16s(const unsigned char *p) {
uint16_t u = (uint16_t)p[0] | (uint16_t)((uint16_t)p[1] << 8);
return (int16_t)u;
}
static void push_small_taps(void) {
int16_t t0[] = {
0, 0,
1, -1,
256, -256,
257, -257,
32767, -32768,
};
int16_t t1[] = { -300, 0, 0, 300, 0, 0 };
int16_t t2[] = { 42, -42, 512, -512 };
int16_t t3[] = { 0, 0, 999, -999, -32768, 32767 };
audio_trace_pcm(AUDIO_TAP_SPU_OUT, t0, 5);
audio_trace_pcm(AUDIO_TAP_CD_IN, t1, 3);
audio_trace_pcm(AUDIO_TAP_HOST, t2, 2);
audio_trace_pcm(AUDIO_TAP_VOICES, t3, 3);
}
static void check_small_stats(void) {
AudioTraceStats st;
audio_trace_get_stats(&st);
if (st.tap_frames[0] != 5 || st.tap_nonzero[0] != 4 ||
st.tap_audible[0] != 2 || st.tap_peak[0] != 32768)
fail("tap0 stats");
if (st.tap_frames[1] != 3 || st.tap_nonzero[1] != 2 ||
st.tap_audible[1] != 2 || st.tap_peak[1] != 300)
fail("tap1 stats");
if (st.tap_frames[2] != 2 || st.tap_nonzero[2] != 2 ||
st.tap_audible[2] != 1 || st.tap_peak[2] != 512)
fail("tap2 stats");
if (st.tap_frames[3] != 3 || st.tap_nonzero[3] != 2 ||
st.tap_audible[3] != 2 || st.tap_peak[3] != 32768)
fail("tap3 stats");
}
static void print_stats(const char *label) {
AudioTraceStats st;
audio_trace_get_stats(&st);
printf("%s", label);
for (int t = 0; t < AUDIO_TAP_COUNT; t++) {
printf(" tap%d=%llu/%llu/%llu/%d/%u",
t,
(unsigned long long)st.tap_frames[t],
(unsigned long long)st.tap_nonzero[t],
(unsigned long long)st.tap_audible[t],
st.tap_peak[t],
audio_trace_tap_rate(t));
}
printf(" pump=%llu/%llu/%llu/%u/%u/%llu/%llu events=%llu total=%llu\n",
(unsigned long long)st.pump_calls,
(unsigned long long)st.pump_skips,
(unsigned long long)st.underruns,
st.queue_hiwater,
st.queue_lowater,
(unsigned long long)st.mute_events,
(unsigned long long)st.unmute_events,
(unsigned long long)st.events_total,
(unsigned long long)audio_trace_events_total());
}
static void push_events(void) {
audio_trace_note_frame(77);
audio_trace_event(AUDIO_EV_RENDER, 2048, 1234);
audio_trace_event(AUDIO_EV_PUMP_SKIP, 222, 0);
audio_trace_event(AUDIO_EV_UNDERRUN, 3, 1);
audio_trace_event(AUDIO_EV_MUTE, 64, 0);
audio_trace_event(AUDIO_EV_UNMUTE, 128, 0);
audio_trace_event(AUDIO_EV_CD_PUSH, 0xABCDEF01u, 17);
}
static void check_events(void) {
AudioTraceStats st;
AudioTraceEvent ev[8];
audio_trace_get_stats(&st);
if (st.pump_calls != 1 || st.pump_skips != 1 || st.underruns != 1 ||
st.mute_events != 1 || st.unmute_events != 1 ||
st.queue_hiwater != 1234 || st.queue_lowater != 1234 ||
st.events_total != 6)
fail("event stats");
if (audio_trace_events_total() != 6) fail("event total");
if (audio_trace_events_get(ev, 8) != 6) fail("event get count");
if (ev[0].seq != 0 || ev[0].sample_idx != 5 || ev[0].frame != 77 ||
ev[0].kind != AUDIO_EV_RENDER || ev[0].a != 2048 || ev[0].b != 1234)
fail("render event payload");
if (ev[5].kind != AUDIO_EV_CD_PUSH || ev[5].a != 0xABCDEF01u ||
ev[5].b != 17)
fail("cd event payload");
}
static void check_post_wrap_event(uint64_t expected_sample_idx) {
AudioTraceStats st;
AudioTraceEvent ev[8];
audio_trace_note_frame(88);
audio_trace_event(AUDIO_EV_DMA_READ, 12, 0x12345678u);
audio_trace_get_stats(&st);
if (st.events_total != 7 || audio_trace_events_total() != 7)
fail("post-wrap event count");
if (audio_trace_events_get(ev, 8) != 7) fail("post-wrap event get count");
if (ev[6].seq != 6 || ev[6].sample_idx != expected_sample_idx ||
ev[6].frame != 88 || ev[6].kind != AUDIO_EV_DMA_READ ||
ev[6].a != 12 || ev[6].b != 0x12345678u)
fail("post-wrap event payload");
}
static void print_events(const char *label) {
AudioTraceEvent ev[8];
uint32_t got = audio_trace_events_get(ev, 8);
printf("%s count=%u", label, got);
for (uint32_t i = 0; i < got; i++) {
printf(" e%u=%llu/%llu/%u/%u/%u/%u",
i,
(unsigned long long)ev[i].seq,
(unsigned long long)ev[i].sample_idx,
ev[i].frame,
ev[i].kind,
ev[i].a,
ev[i].b);
}
printf("\n");
}
static int file_exists(const char *path) {
FILE *fp = fopen(path, "rb");
if (!fp) return 0;
fclose(fp);
return 1;
}
static void push_wrap_tail(void) {
int16_t frame[2];
for (uint32_t i = 0; i < PCM_RING_FRAMES - 5u; i++) {
frame[0] = (int16_t)(i & 0x7FFFu);
frame[1] = (int16_t)(-(int32_t)(i & 0x7FFFu));
audio_trace_pcm(AUDIO_TAP_SPU_OUT, frame, 1);
}
for (int i = 0; i < 4; i++) {
frame[0] = (int16_t)(1000 + i);
frame[1] = (int16_t)(-2000 - i);
audio_trace_pcm(AUDIO_TAP_SPU_OUT, frame, 1);
}
}
static const int16_t *tap_expected(int tap, uint32_t *frames) {
static const int16_t t0[] = {
0, 0, 1, -1, 256, -256, 257, -257, 32767, -32768,
};
static const int16_t t1[] = { -300, 0, 0, 300, 0, 0 };
static const int16_t t2[] = { 42, -42, 512, -512 };
static const int16_t t3[] = { 0, 0, 999, -999, -32768, 32767 };
switch (tap) {
case AUDIO_TAP_SPU_OUT: *frames = 5; return t0;
case AUDIO_TAP_CD_IN: *frames = 3; return t1;
case AUDIO_TAP_HOST: *frames = 2; return t2;
default: *frames = 3; return t3;
}
}
static void check_wav_samples(const char *path, uint32_t rate,
const int16_t *expected, uint32_t frames) {
FILE *fp = fopen(path, "rb");
if (!fp) fail("wav open");
unsigned char data[44 + 64];
size_t want = 44u + (size_t)frames * 4u;
if (want > sizeof(data)) fail("wav test buffer too small");
size_t n = fread(data, 1, want, fp);
fclose(fp);
if (n != want) fail("wav size");
if (memcmp(data, "RIFF", 4) != 0 || memcmp(data + 8, "WAVEfmt ", 8) != 0 ||
memcmp(data + 36, "data", 4) != 0)
fail("wav tags");
if (rd32(data + 24) != rate) fail("wav rate");
if (rd32(data + 40) != frames * 4u) fail("wav data bytes");
for (uint32_t i = 0; i < frames * 2u; i++) {
if (rd16s(data + 44u + i * 2u) != expected[i])
fail("wav data");
}
}
static void check_short_wavs(const char *base_path, int production) {
char path[512];
for (int tap = 0; tap < AUDIO_TAP_COUNT; tap++) {
uint32_t frames = 0;
const int16_t *expected = tap_expected(tap, &frames);
snprintf(path, sizeof(path), "%s_tap%d.wav", base_path, tap);
remove(path);
int64_t wrote = audio_trace_dump_wav(tap, path, 0, frames);
if (production) {
if (wrote != -1) fail("production short wav should be unavailable");
if (file_exists(path)) fail("production short wav created file");
} else {
if (wrote != (int64_t)frames) fail("debug short wav frame count");
check_wav_samples(path, audio_trace_tap_rate(tap), expected, frames);
}
}
}
int main(int argc, char **argv) {
if (argc != 3) fail("usage");
const char *path = argv[1];
int production = strcmp(argv[2], "production") == 0;
audio_trace_init();
audio_trace_set_tap_rate(AUDIO_TAP_SPU_OUT, 48000);
audio_trace_set_tap_rate(AUDIO_TAP_CD_IN, 44101);
audio_trace_set_tap_rate(AUDIO_TAP_HOST, 32000);
audio_trace_set_tap_rate(AUDIO_TAP_VOICES, 22050);
push_small_taps();
check_small_stats();
print_stats("small");
push_events();
check_events();
print_events("events");
check_short_wavs(path, production);
push_wrap_tail();
AudioTraceStats st;
audio_trace_get_stats(&st);
if (st.tap_frames[AUDIO_TAP_SPU_OUT] != PCM_RING_FRAMES + 4ull)
fail("wrapped tap frame total");
check_post_wrap_event(st.tap_frames[AUDIO_TAP_SPU_OUT]);
print_stats("wrap");
print_events("events_after_wrap");
int64_t wrote = audio_trace_dump_wav(AUDIO_TAP_SPU_OUT, path,
(int64_t)PCM_RING_FRAMES, 4);
if (production) {
if (wrote != -1) fail("production wav should be unavailable");
if (file_exists(path)) fail("production wav created file");
} else {
int16_t wrapped[] = {
1000, -2000, 1001, -2001, 1002, -2002, 1003, -2003,
};
if (wrote != 4) fail("debug wav frame count");
check_wav_samples(path, 48000u, wrapped, 4);
{
int16_t oldest[] = { 32767, -32768 };
if (audio_trace_dump_wav(AUDIO_TAP_SPU_OUT, path, 0, 1) != 1)
fail("debug evicted prefix should clamp");
check_wav_samples(path, 48000u, oldest, 1);
}
}
audio_trace_init();
audio_trace_get_stats(&st);
for (int tap = 0; tap < AUDIO_TAP_COUNT; tap++) {
if (st.tap_frames[tap] || st.tap_nonzero[tap] || st.tap_audible[tap] ||
st.tap_peak[tap] || audio_trace_tap_total(tap))
fail("reset tap");
}
if (st.events_total || st.pump_calls || st.pump_skips || st.underruns ||
st.queue_hiwater || st.queue_lowater || st.mute_events ||
st.unmute_events || audio_trace_events_total())
fail("reset events");
print_stats("reset");
return 0;
}
"""
def default_cc() -> str:
if DEFAULT_GCC.exists():
return str(DEFAULT_GCC).replace("\\", "/")
for name in ("cc", "gcc", "clang"):
path = shutil.which(name)
if path:
return path
return str(DEFAULT_GCC).replace("\\", "/")
def normalize_cc(cc: str) -> str:
if "\\" in cc and len(cc) >= 3 and cc[1] == ":":
return cc.replace("\\", "/")
return cc
def build(cc: str, work: pathlib.Path, production: bool) -> pathlib.Path:
source = work / "audio_trace_guard_driver.c"
source.write_text(DRIVER_C, encoding="utf-8")
exe = work / ("audio_trace_prod.exe" if production else "audio_trace_debug.exe")
cmd = [
cc,
"-std=c11",
"-O2",
"-fno-lto",
"-I",
str(ROOT / "runtime" / "include"),
]
if production:
cmd.append("-DPSX_NO_DEBUG_TOOLS=1")
cmd.extend([
str(source),
str(ROOT / "runtime" / "src" / "audio_trace.c"),
"-o",
str(exe),
])
subprocess.run(cmd, cwd=work, check=True)
return exe
def run(exe: pathlib.Path, path: pathlib.Path, mode: str) -> str:
return subprocess.run(
[str(exe), str(path), mode],
cwd=exe.parent,
check=True,
text=True,
stdout=subprocess.PIPE,
).stdout.strip()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cc", default=default_cc())
args = parser.parse_args()
cc = normalize_cc(args.cc)
with tempfile.TemporaryDirectory(prefix="psx-audio-trace-") as td:
work = pathlib.Path(td)
debug = build(cc, work, False)
prod = build(cc, work, True)
debug_out = run(debug, work / "debug.wav", "debug")
prod_out = run(prod, work / "prod.wav", "production")
if debug_out != prod_out:
raise AssertionError(f"stats/event output changed:\n{debug_out}\n{prod_out}")
print(debug_out)
print("PASS: audio trace PCM history is debug-tool only; stats/events match")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except AssertionError as exc:
print(f"FAIL: {exc}")
sys.exit(1)
except subprocess.CalledProcessError as exc:
print(f"FAIL: command exited {exc.returncode}: {' '.join(map(str, exc.cmd))}")
sys.exit(1)
+51 -2
View File
@@ -72,6 +72,15 @@ int main(int argc,char **argv){
printf("driver=%s renderer=%s scale=%d\n",glGetString(GL_VERSION),glGetString(GL_RENDERER),scale);
glb_set_draw_area(0,0,1023,511);glb_set_mask_bits(0,0);glb_set_semi_transparency(0,0);glb_set_color_modulation(128,128,128,1);
verify("initial upload");
/* Adaptive backdrop reflection must retain every edge texel at both native
* and supersampled scales; the ordinary rect stays forward-facing. */
for(int x=0;x<16;++x) glb_vram_write(512+x,0,(uint16_t)(0x400+x+1));
glb_draw_textured_rect(700,200,16,1,0,0,0,0,0x108);
glb_draw_textured_rect_scaled(720,200,16,1,15,0,-1,1,0,0,0x108);
for(int x=0;x<16;++x) {
check(glb_vram_read(700+x,200)==0x401+x,"forward panorama texel");
check(glb_vram_read(720+x,200)==0x410-x,"reflected panorama texel");
}
glb_draw_flat_rect(1020,511,1,1,0x7fff);
check(glb_vram_read(1020,511)==0x7fff,"test pixel value");
GlCohEvent event;int found=0;
@@ -109,8 +118,8 @@ int main(int argc,char **argv){
check(glb_vram_read(40,40)==0,"depth24 cleared band immediate CPU read");
check(glb_vram_read(33,33)==0x3210,"newer overlapping texture survives clear");
verify("depth24 leave coherence without subsequent primitive");
/* Retained banks use their own texels AND CLUT, and bank/VRAM transitions
* must split batches without changing painter order. No retail assets. */
/* Run retained-bank tests with the normal draw area before the wide-view
* regression narrows it to the gameplay rectangle. */
static uint16_t bank[256*128];
bank[0]=0x001f; bank[1]=0x03e0; bank[2]=0x7c00;
bank[16]=0x1111; /* 4-bit indices, CLUT at (0,0) */
@@ -127,7 +136,47 @@ int main(int argc,char **argv){
check(gl_renderer_select_texture_bank(0),"reset bank after direct texture");
check(glb_vram_read(302,252)==0x001f,"retained 16-bit texel");
verify("retained banks and original VRAM ordered together");
/* A streamed scene can retain indices while CLUT uploads/fades continue.
* Alternate both modes of the SAME bank with pending draws: palette source
* must participate in the batch key, and stock packets must reset it. */
glb_vram_write(1,0,0x7c00);
check(gl_renderer_select_texture_bank_live_clut(7),"select bank with live CLUT");
glb_draw_shaded_textured_triangle(400,250,64,0,0x808080,432,250,64,0,0x808080,400,282,64,0,0x808080,0,0,0,1);
check(gl_renderer_select_texture_bank(7),"same bank with retained CLUT");
glb_draw_shaded_textured_triangle(440,250,64,0,0x808080,472,250,64,0,0x808080,440,282,64,0,0x808080,0,0,0,1);
check(glb_vram_read(402,252)==0x7c00,"live guest palette used");
check(glb_vram_read(442,252)==0x03e0,"same-bank palette source is a batch key");
glb_vram_write(1,0,0x001f);
check(gl_renderer_select_texture_bank_live_clut(7),"live CLUT after update");
glb_draw_shaded_textured_triangle(480,250,64,0,0x808080,512,250,64,0,0x808080,480,282,64,0,0x808080,0,0,0,1);
check(gl_renderer_select_texture_bank(0),"reset live-CLUT bank");
check(glb_vram_read(482,252)==0x001f,"palette update visible without replacing indices");
verify("retained indices with animated guest CLUT");
verify_bank_batching();
/* World and UI use different origins in an anchored wide frame. Keep the
* canonical-center optimization enabled to catch an erroneous blit over the
* completed mirror, and change origins with a pending flat batch. */
glb_set_draw_area(0,0,319,239);glb_set_precise_triangle(0,0,0,0,0,0,0);
glb_wide_configure(426,53);glb_wide_set_target(0);
s_wide_fast=1;
uint32_t *wide_pixels=calloc((size_t)426*240*scale*scale,sizeof(uint32_t));
if(!wide_pixels)return 2;
for(int shift=-53;shift<=53;shift+=53){
glb_wide_clear(0,0,240,0);
glb_wide_set_view(1,shift,0,0);
glb_draw_flat_rect(-106,0,532,240,0x7c00);
glb_draw_flat_rect(160,40,3,3,0x03e0);
glb_wide_set_view(1,0,0,0);
glb_draw_flat_rect(160,20,3,3,0x001f);
check(glb_render_wide_display(wide_pixels,426*scale*4,0,0,240)>0,"anchored wide readback");
check(wide_pixels[(40*scale)*(426*scale)+(213+shift)*scale]==0xff00f800u,"anchored world marker");
check(wide_pixels[(20*scale)*(426*scale)+213*scale]==0xfff80000u,"centered dialogue marker");
check(wide_pixels[(60*scale)*(426*scale)]==0xff0000f8u,"anchored left edge");
check(wide_pixels[(60*scale)*(426*scale)+425*scale]==0xff0000f8u,"anchored right edge");
}
glb_wide_set_view(0,0,0,0);
check(wide_dx()==53,"disabled view preserves original origin");
free(wide_pixels);
printf("checks=%d failures=%d\n",checks,failures);
gl_renderer_shutdown();SDL_DestroyWindow(win);SDL_Quit();return failures?1:0;
}
+510
View File
@@ -0,0 +1,510 @@
#!/usr/bin/env python3
"""Validate production GP0 history gating against actual gpu.c.
This is a parser/register/VRAM regression with renderer stubs, not a full
backend rendering-correctness test.
"""
from __future__ import annotations
import argparse
import pathlib
import platform
import shutil
import subprocess
import sys
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[2]
GPU_C = ROOT / "runtime" / "src" / "gpu.c"
DEFAULT_GCC = pathlib.Path(r"C:\msys64\mingw64\bin\gcc.exe")
DRIVER_C = r"""
#include "gpu.h"
#include <stdlib.h>
#include <stdint.h>
#include <stdio.h>
extern uint64_t s_frame_count;
uint32_t gpu_get_opcode_count(uint8_t op);
uint64_t gpu_get_gp0_count(void);
void gpu_get_gp0_stats(uint64_t* nop, uint64_t* fill, uint64_t* draw,
uint64_t* env, uint64_t* copy);
uint32_t gpu_snapshot_bytes(void);
void gpu_snapshot_write(uint8_t *p);
static uint32_t timeline_hash = 2166136261u;
static int checkpoint_failed = 0;
static FILE *trace_file;
static uint32_t mix(uint32_t h, uint32_t v) {
return (h ^ v) * 16777619u;
}
static void checkpoint(void) {
uint32_t n = gpu_snapshot_bytes();
uint8_t *snap = (uint8_t *)malloc(n ? n : 1u);
if (!snap) {
checkpoint_failed = 1;
return;
}
gpu_snapshot_write(snap);
if (fwrite(&n, sizeof(n), 1, trace_file) != 1 ||
fwrite(snap, 1, n, trace_file) != n) checkpoint_failed = 1;
timeline_hash = mix(timeline_hash, n);
for (uint32_t i = 0; i < n; i++) timeline_hash = mix(timeline_hash, snap[i]);
free(snap);
for (int y = 0; y < 512; y++) {
uint16_t row[1024];
for (int x = 0; x < 1024; x++) {
row[x] = gpu_vram_peek(x, y);
timeline_hash = mix(timeline_hash, row[x]);
}
if (fwrite(row, sizeof(row), 1, trace_file) != 1) checkpoint_failed = 1;
}
}
static void emit_env(uint32_t word) {
gpu_write_gp0(word);
s_frame_count++;
checkpoint();
}
static void emit_fill(uint32_t color, uint32_t xy, uint32_t wh) {
gpu_write_gp0(0x02000000u | color);
gpu_write_gp0(xy);
gpu_write_gp0(wh);
s_frame_count++;
checkpoint();
}
static void emit_upload_2x1(uint32_t xy, uint32_t pixels) {
gpu_write_gp0(0xA0000000u);
gpu_write_gp0(xy);
gpu_write_gp0((1u << 16) | 2u);
gpu_write_gp0(pixels);
s_frame_count++;
checkpoint();
}
static void emit_copy(uint32_t src_xy, uint32_t dst_xy, uint32_t wh) {
gpu_write_gp0(0x80000000u);
gpu_write_gp0(src_xy);
gpu_write_gp0(dst_xy);
gpu_write_gp0(wh);
s_frame_count++;
checkpoint();
}
static void emit_polyline(void) {
gpu_write_gp0(0x4800007Fu);
gpu_write_gp0((20u << 16) | 10u);
/* The first two vertices are mandatory, before any terminator. */
gpu_write_gp0((25u << 16) | 15u);
gpu_write_gp0(0x55555555u);
s_frame_count++;
checkpoint();
}
static uint32_t hash_state(void) {
GpuDrawArea a;
uint64_t nop = 0, fill = 0, draw = 0, env = 0, copy = 0;
gpu_get_draw_area(&a);
gpu_get_gp0_stats(&nop, &fill, &draw, &env, &copy);
uint32_t h = 2166136261u;
#define MIX(v) do { h = (h ^ (uint32_t)(v)) * 16777619u; } while (0)
MIX(gpu_get_gp0_count());
MIX(nop); MIX(fill); MIX(draw); MIX(env); MIX(copy);
MIX(gpu_get_opcode_count(0x00)); MIX(gpu_get_opcode_count(0x02));
MIX(gpu_get_opcode_count(0xA0)); MIX(gpu_get_opcode_count(0xE1));
MIX(gpu_get_opcode_count(0xE3)); MIX(gpu_get_opcode_count(0xE4));
MIX(gpu_get_opcode_count(0xE5)); MIX(gpu_get_opcode_count(0xE6));
MIX(a.left); MIX(a.top); MIX(a.right); MIX(a.bottom);
MIX((uint32_t)a.offset_x); MIX((uint32_t)a.offset_y);
MIX(gpu_vram_peek(16, 5)); MIX(gpu_vram_peek(31, 5));
MIX(gpu_vram_peek(40, 9)); MIX(gpu_vram_peek(41, 9));
MIX(gpu_read_gpustat());
MIX(gpu_ws_census_seq());
MIX(timeline_hash);
return h;
#undef MIX
}
int main(int argc, char **argv) {
GpuGp0RingEntry entries[16], copy_entries[16], poly_entries[16];
uint32_t oldest = 99, newest = 99;
if (argc != 2 || !(trace_file = fopen(argv[1], "wb"))) return 2;
gpu_init();
gpu_ws_census_set(0);
checkpoint();
gpu_set_gp0_source(0x00123400u);
emit_env(0xE10003FFu);
gpu_set_gp0_source(0x00123404u);
emit_env(0xE3000000u | (3u << 10) | 2u);
gpu_set_gp0_source(0x00123408u);
emit_env(0xE4000000u | (80u << 10) | 90u);
gpu_set_gp0_source(0x0012340Cu);
emit_env(0xE5000000u | (8u << 11) | 7u);
gpu_set_gp0_source(0x00123410u);
emit_env(0xE6000001u);
gpu_set_gp0_source(0x00123414u);
emit_fill(0x0000FFu, (5u << 16) | 16u, (1u << 16) | 16u);
gpu_set_gp0_source(0x00123418u);
emit_upload_2x1((9u << 16) | 40u, 0x9ABC1234u);
gpu_set_gp0_source(0x0012341Cu);
emit_copy((9u << 16) | 40u, (10u << 16) | 42u, (1u << 16) | 2u);
gpu_set_gp0_source(0x00123420u);
emit_polyline();
gpu_set_gp0_source(0x00123424u);
emit_env(0x00000000u);
if (fclose(trace_file) != 0) checkpoint_failed = 1;
if (checkpoint_failed) {
fprintf(stderr, "checkpoint allocation/write failed\n");
return 2;
}
gpu_gp0_ring_frame_span(&oldest, &newest);
int dumped = gpu_gp0_ring_dump_frame(0, entries, 16);
int copy_dumped = gpu_gp0_ring_dump_frame(7, copy_entries, 16);
int poly_dumped = gpu_gp0_ring_dump_frame(8, poly_entries, 16);
printf("hash=%08x total=%llu cap=%u max=%u span=%u:%u dump0=%d "
"src=%08x op=%02x cmd0=%08x "
"copydump=%d copysrc=%08x copyop=%02x copycsp=%08x "
"polydump=%d polyop=%02x polycmd0=%08x\n",
hash_state(), (unsigned long long)gpu_gp0_ring_total(),
gpu_gp0_ring_capacity(), gpu_gp0_ring_max_words(), oldest, newest,
dumped, dumped > 0 ? entries[0].src_addr : 0u,
dumped > 0 ? entries[0].opcode : 0u,
dumped > 0 ? entries[0].cmd[0] : 0u,
copy_dumped, copy_dumped > 0 ? copy_entries[0].src_addr : 0u,
copy_dumped > 0 ? copy_entries[0].opcode : 0u,
copy_dumped > 0 ? copy_entries[0].csp : 0u,
poly_dumped, poly_dumped > 0 ? poly_entries[0].opcode : 0u,
poly_dumped > 0 ? poly_entries[0].cmd[0] : 0u);
return 0;
}
"""
STUBS_C = r"""
#include "gpu.h"
#include "gpu_render.h"
#include "cpu_state.h"
#include "color_lut.h"
#include "ws_aspect_cone_math.h"
#include "ws_ui_group.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
uint64_t s_frame_count = 0;
uint32_t g_debug_last_store_pc = 0x80012340u;
uint32_t g_psx_ram_size = 0x00200000u;
uint32_t g_psx_ram_mask = 0x001FFFFFu;
void psx_video_timing_set_pal(int pal) { (void)pal; }
GrBackend gr_backend(void) { return GR_BACKEND_SOFTWARE; }
int gl_renderer_texture_banks_supported(void) { return 0; }
int gl_renderer_select_texture_bank(uint16_t id) { return id == 0; }
int gl_renderer_select_texture_bank_live_clut(uint16_t id) { return id == 0; }
uint16_t mod_texture_packet_bank(uint32_t p, const uint32_t *words, uint32_t n)
{ (void)p; (void)words; (void)n; return 0; }
int mod_texture_packet_precision(uint32_t p, float q[3], float xy[6])
{ (void)p; (void)q; (void)xy; return 0; }
void psx_host_write_half(uint32_t addr, uint16_t val) { (void)addr; (void)val; }
uint32_t g_debug_current_func_addr = 0x80045670u;
CPUState *debug_cpu_ptr = 0;
uint32_t i_stat = 0;
uint32_t i_mask = 0;
static uint16_t *g_vram;
uint32_t debug_guest_ra(void) { return 0x8000AAAAu; }
uint32_t debug_guest_sp(void) { return 0x8010FFF0u; }
uint8_t *memory_get_ram_ptr(void) { static uint8_t ram[2 * 1024 * 1024]; return ram; }
uint16_t psx_read_half(uint32_t addr) { (void)addr; return 0; }
uint8_t psx_read_byte(uint32_t addr) { (void)addr; return 0; }
uint32_t psx_read_word(uint32_t addr) { (void)addr; return 0; }
void psx_fatal_halt(const char *reason) { fprintf(stderr, "%s\n", reason); abort(); }
void crash_trace_note_gpu_fatal(uint32_t opcode, uint32_t word) { (void)opcode; (void)word; }
void event_ring_note_gpu_irq(uint32_t stat) { (void)stat; }
int g_psx_vram_dirty_tracking = 0;
int g_exec_phase = 0;
void gpu_vram_dirty_mark_all(void) {}
void gpu_vram_dirty_mark_row_impl(uint32_t row) { (void)row; }
void gpu_vram_dirty_mark_rect(uint32_t x, uint32_t y, uint32_t w, uint32_t h)
{ (void)x; (void)y; (void)w; (void)h; }
void text_xlate_vram_upload(uint16_t x, uint16_t y, uint16_t w, uint16_t h)
{ (void)x; (void)y; (void)w; (void)h; }
int ws_cull_should_keep(uint32_t addr) { (void)addr; return 1; }
int ws_ui_group_should_keep(uint32_t addr) { (void)addr; return 1; }
uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { return address; }
void ws_ui_group_assign(WsUiGroupItem *items, size_t count,
int32_t display_width, int dense_menu)
{ (void)items; (void)count; (void)display_width; (void)dense_menu; }
int32_t ws_ui_anchor_for_bounds(int32_t x, int32_t width, int32_t display_width)
{ (void)x; (void)width; return display_width / 2; }
int gte_geometry_correction_enabled(void) { return 0; }
void pgxp_set_enabled(int enabled) { (void)enabled; }
int pgxp_get_gte_sxy_checked(uint32_t slot, uint32_t packed, int require_valid,
int32_t *x16, int32_t *y16)
{ (void)slot; (void)packed; (void)require_valid; (void)x16; (void)y16; return 0; }
int pgxp_get_precise_vertex(uint32_t addr, uint32_t packet_word,
int32_t int_x, int32_t int_y,
int32_t *x16, int32_t *y16, uint16_t *sz)
{ (void)addr; (void)packet_word; (void)int_x; (void)int_y; (void)x16; (void)y16; (void)sz; return 0; }
int gte_precision_load_word(uint32_t addr, uint32_t packed,
int32_t *x16, int32_t *y16, uint16_t *z)
{ (void)addr; (void)packed; (void)x16; (void)y16; (void)z; return 0; }
void pgxp_invalidate_all(void) {}
void pgxp_invalidate_word(uint32_t addr) { (void)addr; }
void psx_irq_raise(uint32_t bit, uint32_t detail) { (void)bit; (void)detail; }
void event_ring_record_aux(uint16_t kind, uint8_t detail, uint32_t aux)
{ (void)kind; (void)detail; (void)aux; }
int psx_get_in_exception(void) { return 0; }
int psx_netplay_active(void) { return 0; }
int sio_hold_present_for_card(void) { return 0; }
uint32_t psx_compiled_irq_resume_pc = 0;
uint32_t psx_last_irq_check_pc = 0;
uint32_t psx_netplay_rb_sticky_bb_pc = 0;
void mod_runtime_on_vblank(void) {}
void sio_ape_card_unstick_pump(void) {}
void psx_write_half(uint32_t addr, uint16_t val) { (void)addr; (void)val; }
int mdec_recently_active(uint32_t within_frames) { (void)within_frames; return 0; }
uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels)
{ (void)extent_pixels; return vanilla; }
int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y,
int32_t fx, int32_t fz, int32_t fy,
uint32_t threshold, int extent_pixels)
{ (void)x; (void)z; (void)y; (void)fx; (void)fz; (void)fy; (void)threshold; (void)extent_pixels; return 0; }
uint32_t sw_perspective_triangle_count(void) { return 0; }
bool screen_kind_from_name(const char* name, ScreenKind* out)
{ (void)name; if (out) *out = SCREEN_RAW; return 0; }
ColorLut* color_lut_create(const ColorSettings* settings)
{ (void)settings; return 0; }
void color_lut_destroy(ColorLut* lut) { (void)lut; }
bool color_lut_is_passthrough(const ColorLut* lut) { (void)lut; return 1; }
void color_lut_map555(const ColorLut* lut, uint16_t bgr555,
uint8_t* r, uint8_t* g, uint8_t* b)
{ (void)lut; if (r) *r = (uint8_t)(bgr555 & 0x1F); if (g) *g = (uint8_t)((bgr555 >> 5) & 0x1F); if (b) *b = (uint8_t)((bgr555 >> 10) & 0x1F); }
int ws_sprt_fixed_transform(int32_t *x, int32_t y, int w)
{ (void)x; (void)y; return w; }
int ws_nw_hud_shift(int32_t x, int w) { (void)x; (void)w; return 0; }
int ws_is_fb_base(int x) { (void)x; return 0; }
int ws_local_viewport_draw_target(int *base) { if (base) *base = 0; return 0; }
void ws_nw_sync_target(void) {}
int ws_active(void) { return 0; }
int ws_engaged(void) { return 0; }
void gr_init(uint16_t *vram) { g_vram = vram; }
uint16_t gr_vram_read(int x, int y) { return g_vram[(y & 511) * 1024 + (x & 1023)]; }
void gr_vram_transfer_in(int x, int y, int w, int h, const uint16_t *pixels)
{
for (int yy = 0; yy < h; yy++)
for (int xx = 0; xx < w; xx++)
g_vram[((y + yy) & 511) * 1024 + ((x + xx) & 1023)] = pixels[yy * w + xx];
}
void gr_fill_rect(int x, int y, int w, int h, uint16_t color)
{
for (int yy = 0; yy < h; yy++)
for (int xx = 0; xx < w; xx++)
g_vram[((y + yy) & 511) * 1024 + ((x + xx) & 1023)] = color;
}
void gr_wide_clear(int x, int y, int h, uint16_t color)
{ (void)x; (void)y; (void)h; (void)color; }
void gr_wide_disable_target(void) {}
void gr_wide_configure(int width, int offset) { (void)width; (void)offset; }
void gr_wide_set_target(int base) { (void)base; }
void gr_wide_set_view(int active, int shift, int pad_left, int pad_right)
{ (void)active; (void)shift; (void)pad_left; (void)pad_right; }
void gr_wide_clear_margins(int base, int y, int h, uint16_t color, int mode)
{ (void)base; (void)y; (void)h; (void)color; (void)mode; }
void gr_set_draw_area(int l, int t, int r, int b) { (void)l; (void)t; (void)r; (void)b; }
void gr_set_draw_offset(int x, int y) { (void)x; (void)y; }
void gr_set_mask_bits(int set, int check) { (void)set; (void)check; }
void gr_set_texture_window(uint32_t value) { (void)value; }
void gr_set_semi_transparency(int enabled, int mode) { (void)enabled; (void)mode; }
void gr_set_color_modulation(int r, int g, int b, int raw)
{ (void)r; (void)g; (void)b; (void)raw; }
void gr_set_perspective_triangle(int enabled, float a, float b, float c)
{ (void)enabled; (void)a; (void)b; (void)c; }
void gr_set_precise_triangle(int enabled, int32_t ax, int32_t ay, int32_t bx,
int32_t by, int32_t cx, int32_t cy)
{ (void)enabled; (void)ax; (void)ay; (void)bx; (void)by; (void)cx; (void)cy; }
void gr_copy_rect(int sx, int sy, int dx, int dy, int w, int h)
{ (void)sx; (void)sy; (void)dx; (void)dy; (void)w; (void)h; }
void gr_draw_flat_rect(int x, int y, int w, int h, uint16_t color)
{ (void)x; (void)y; (void)w; (void)h; (void)color; }
void gr_draw_flat_triangle(int x0,int y0,int x1,int y1,int x2,int y2,uint16_t c)
{ (void)x0;(void)y0;(void)x1;(void)y1;(void)x2;(void)y2;(void)c; }
void gr_draw_gouraud_triangle(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int x2,int y2,uint16_t c2)
{ (void)x0;(void)y0;(void)c0;(void)x1;(void)y1;(void)c1;(void)x2;(void)y2;(void)c2; }
void gr_draw_line(int x0,int y0,int x1,int y1,uint16_t c)
{ (void)x0;(void)y0;(void)x1;(void)y1;(void)c; }
void gr_draw_shaded_line(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1)
{ (void)x0;(void)y0;(void)c0;(void)x1;(void)y1;(void)c1; }
void gr_draw_textured_rect(int x,int y,int w,int h,int u,int v,uint16_t clut_x,uint16_t clut_y,uint16_t texpage)
{ (void)x;(void)y;(void)w;(void)h;(void)u;(void)v;(void)clut_x;(void)clut_y;(void)texpage; }
void gr_draw_textured_rect_scaled(int x,int y,int w,int h,int u0,int v0,int u1,int v1,uint16_t clut_x,uint16_t clut_y,uint16_t texpage)
{ (void)x;(void)y;(void)w;(void)h;(void)u0;(void)v0;(void)u1;(void)v1;(void)clut_x;(void)clut_y;(void)texpage; }
void gr_draw_textured_triangle(int x0,int y0,int u0,int v0,int x1,int y1,int u1,int v1,int x2,int y2,int u2,int v2,uint16_t clut_x,uint16_t clut_y,uint16_t texpage)
{ (void)x0;(void)y0;(void)u0;(void)v0;(void)x1;(void)y1;(void)u1;(void)v1;(void)x2;(void)y2;(void)u2;(void)v2;(void)clut_x;(void)clut_y;(void)texpage; }
void gr_draw_shaded_textured_triangle(int x0,int y0,int u0,int v0,uint32_t c0,int x1,int y1,int u1,int v1,uint32_t c1,int x2,int y2,int u2,int v2,uint32_t c2,uint16_t clut_x,uint16_t clut_y,uint16_t texpage,int raw)
{ (void)x0;(void)y0;(void)u0;(void)v0;(void)c0;(void)x1;(void)y1;(void)u1;(void)v1;(void)c1;(void)x2;(void)y2;(void)u2;(void)v2;(void)c2;(void)clut_x;(void)clut_y;(void)texpage;(void)raw; }
"""
def fail(message: str) -> None:
raise AssertionError(message)
def default_cc() -> str:
if DEFAULT_GCC.exists():
return str(DEFAULT_GCC).replace("\\", "/")
for name in ("cc", "gcc", "clang"):
path = shutil.which(name)
if path:
return path
return str(DEFAULT_GCC)
def function_body(source: str, signature: str) -> str:
start = source.index(signature)
brace = source.index("{", start)
depth = 0
for pos in range(brace, len(source)):
if source[pos] == "{":
depth += 1
elif source[pos] == "}":
depth -= 1
if depth == 0:
return source[brace : pos + 1]
raise AssertionError(f"unterminated function: {signature}")
def assert_source_shape(gpu: str) -> None:
guard = gpu.index("#ifndef PSX_NO_DEBUG_TOOLS", gpu.index("debug tools only"))
alt = gpu.index("#else", guard)
end = gpu.index("#endif", alt)
debug_block = gpu[guard:alt]
prod_block = gpu[alt:end]
for token in ("GP0_RING_CAP", "static GpuGp0RingEntry *gp0_ring",
"gp0_capture_builder_chain", "g_gp0_last_copy_sp",
"debug_guest_ra()", "debug_guest_sp()"):
if token not in debug_block:
fail(f"debug GP0 history block lost {token}")
if token in prod_block:
fail(f"production GP0 history block still references {token}")
for token in ("gpu_gp0_ring_total(void) { return 0; }",
"gpu_gp0_ring_capacity(void) { return 0; }",
"return GPU_GP0_RING_MAX_WORDS;",
"return 0;"):
if token not in prod_block:
fail(f"production unavailable accessor missing {token}")
set_source = function_body(gpu, "void gpu_set_gp0_source(")
if "gp0_next_source_addr = addr;" not in set_source:
fail("gpu_set_gp0_source no longer preserves source tracking")
execute = function_body(gpu, "static void gp0_execute_command(")
order = [execute.index(token) for token in (
"gp0_opcode_count[opcode]++",
"gp0_ring_record(gp0_cmd_buf, gp0_words_needed)",
"ws_bg_phase_note(opcode)",
"ws_census_record(opcode, cvx, cvy)",
"ws_note_overhang(opcode)",
)]
if order != sorted(order):
fail("GP0 command accounting/ring/WS ordering changed")
if "gp0_ring_record(hdr_only, 1);" not in gpu:
fail("polyline header capture hook was removed")
def write_sources(work: pathlib.Path) -> None:
(work / "driver.c").write_text(DRIVER_C, encoding="utf-8")
(work / "stubs.c").write_text(STUBS_C, encoding="utf-8")
def build(cc: str, work: pathlib.Path, name: str, prod: bool) -> pathlib.Path:
exe = work / f"{name}.exe"
cmd = [
cc, "-std=c11", "-O2", "-ffunction-sections", "-fdata-sections",
"-I", str(ROOT / "runtime" / "include"),
]
if prod:
cmd.append("-DPSX_NO_DEBUG_TOOLS")
cmd.extend([
str(work / "driver.c"),
str(GPU_C),
str(work / "stubs.c"),
])
if platform.system() == "Darwin":
cmd.append("-Wl,-dead_strip")
else:
cmd.append("-Wl,--gc-sections")
cmd.extend(["-o", str(exe)])
subprocess.run(cmd, cwd=work, check=True)
return exe
def run(exe: pathlib.Path) -> dict[str, str]:
proc = subprocess.run([str(exe), str(exe.with_suffix(".state"))],
cwd=exe.parent, check=True, text=True,
stdout=subprocess.PIPE)
return dict(part.split("=", 1) for part in proc.stdout.strip().split())
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cc", default=default_cc())
args = parser.parse_args()
cc = args.cc.replace("\\", "/")
assert_source_shape(GPU_C.read_text(encoding="utf-8"))
with tempfile.TemporaryDirectory(prefix="psx-gp0-history-") as td:
work = pathlib.Path(td)
write_sources(work)
debug = build(cc, work, "debug", prod=False)
prod = build(cc, work, "prod", prod=True)
debug_out = run(debug)
prod_out = run(prod)
debug_state = debug.with_suffix(".state").read_bytes()
prod_state = prod.with_suffix(".state").read_bytes()
if not debug_state or debug_state != prod_state:
fail("production gate changed checkpoint GPU snapshot/VRAM bytes")
if debug_out["hash"] != prod_out["hash"]:
fail("production GP0 history gate changed guest-visible GPU state")
if prod_out["total"] != "0" or prod_out["cap"] != "0":
fail("production GP0 history remains available")
if prod_out["span"] != "0:0" or prod_out["dump0"] != "0":
fail("production GP0 history accessors should report empty capture")
if prod_out["copydump"] != "0" or prod_out["polydump"] != "0":
fail("production GP0 history frame dumps should be empty")
if debug_out["total"] != "10" or int(debug_out["cap"]) == 0:
fail(f"unexpected debug GP0 history: {debug_out}")
if debug_out["src"] != "00123400" or debug_out["op"] != "e1":
fail("debug GP0 history lost source/opcode tracking")
if debug_out["cmd0"] != "e10003ff":
fail("debug GP0 history lost command-word capture")
if debug_out["copydump"] != "1" or debug_out["copysrc"] != "0012341c":
fail("debug GP0 history lost copy source tracking")
if debug_out["copyop"] != "80" or debug_out["copycsp"] != "8010fff0":
fail("debug GP0 history lost copy builder diagnostics")
if debug_out["polydump"] != "1" or debug_out["polyop"] != "48":
fail("debug GP0 history lost polyline header capture")
if debug_out["polycmd0"] != "4800007f":
fail("debug GP0 history lost polyline command word")
print("PASS: actual gpu.c production GP0 history gate preserves GPU state")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except AssertionError as exc:
print(f"FAIL: {exc}")
sys.exit(1)
except subprocess.CalledProcessError as exc:
print(f"FAIL: command exited {exc.returncode}: {' '.join(map(str, exc.cmd))}")
sys.exit(1)
@@ -23,7 +23,8 @@ uint32_t g_psx_ram_mask = 0x001FFFFFu;
* additional exported command handlers despite --gc-sections. */
GrBackend gr_backend(void) { return GR_BACKEND_SOFTWARE; }
int gl_renderer_texture_banks_supported(void) { return 0; }
int gl_renderer_select_texture_bank(uint16_t id) { (void)id; return 0; }
int gl_renderer_select_texture_bank(uint16_t id) { return id == 0; }
int gl_renderer_select_texture_bank_live_clut(uint16_t id) { return id == 0; }
uint16_t mod_texture_packet_bank(uint32_t source, const uint32_t *words,
uint32_t count) {
(void)source; (void)words; (void)count;
@@ -42,6 +43,7 @@ static struct {
int u, v;
uint16_t clut_x, clut_y, texpage;
} last_textured_rect;
static struct { int calls, x, y, w, h, u0, v0, u1, v1; } last_scaled_rect;
static uint32_t pack_vertex(int16_t x, int16_t y) {
return (uint16_t)x | ((uint32_t)(uint16_t)y << 16);
@@ -335,7 +337,11 @@ void gr_draw_textured_rect_scaled(int x, int y, int w, int h,
int u0, int v0, int u1, int v1,
uint16_t clut_x, uint16_t clut_y,
uint16_t texpage) {
(void)x; (void)y; (void)w; (void)h; (void)u0; (void)v0; (void)u1;
last_scaled_rect.calls++;
last_scaled_rect.x=x; last_scaled_rect.y=y;
last_scaled_rect.w=w; last_scaled_rect.h=h;
last_scaled_rect.u0=u0; last_scaled_rect.v0=v0;
last_scaled_rect.u1=u1; last_scaled_rect.v1=v1;
(void)v1; (void)clut_x; (void)clut_y; (void)texpage;
}
void gr_draw_line(int x0, int y0, int x1, int y1, uint16_t color) {
@@ -373,6 +379,9 @@ void gr_get_draw_area(int *x1, int *y1, int *x2, int *y2) {
void gr_set_draw_offset(int x, int y) { (void)x; (void)y; }
int gr_wide_supported(void) { return 1; }
void gr_wide_configure(int wide_w, int offset) { (void)wide_w; (void)offset; }
void gr_wide_set_view(int enabled, int shift, int left, int right) {
(void)enabled; (void)shift; (void)left; (void)right;
}
void gr_wide_set_target(int base_x) { (void)base_x; }
void gr_wide_disable_target(void) {}
void gr_wide_clear(int base_x, int y, int h, uint16_t color) {
@@ -13,12 +13,11 @@ def main():
assert static_dispatch.index('g_exec_phase = 3;') < static_dispatch.index(
'psx_overlay_dispatch(cpu, addr);') < static_dispatch.index(
'g_exec_phase = previous_phase;') < static_dispatch.index('if (handled) return 1;')
assert source.count('psx_mod_function_entry(cpu, addr);') == 1
assert source.count('psx_mod_function_entry(cpu, addr)') == 1
# Every interpreted entry reaches these calls: without an active hook they
# must cost one load, never a call into the plugin table.
assert 'if (g_psx_mod_function_entry_hooks) psx_mod_function_entry(cpu, addr);' in source
assert ('if (g_psx_mod_function_entry_hooks)\n'
' psx_mod_function_entry(cpu, target);') in source
assert 'if (g_psx_mod_function_entry_hooks && psx_mod_function_entry(cpu, addr)) return 1;' in source
assert 'if (g_psx_mod_function_entry_hooks &&\n' in source
local_start = source.index('if (allow_local_dirty_flow && target != 0 &&')
local_end = source.index('current_page = target_phys >> 12;', local_start)
local = source[local_start:local_end]
@@ -28,9 +27,9 @@ def main():
assert 'g_dirty_interp_chain_target = target;' in probe
assert 'OV_FPLOG_RET1();' in probe
assert 'psx_overlay_dispatch(cpu' not in probe
assert local.count('psx_mod_function_entry(cpu, target);') == 1
assert local.count('psx_mod_function_entry(cpu, target)') == 1
assert local.index('overlay_loader_dispatch(cpu, target)') < local.index(
'psx_mod_function_entry(cpu, target);') < local.index('pc = target;')
'psx_mod_function_entry(cpu, target)') < local.index('pc = target;')
print('interpreter mod entry guards: PASS')
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""Compile actual memory.c to validate production debug-trace guards."""
from __future__ import annotations
import argparse
import pathlib
import shutil
import subprocess
import sys
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[2]
MEMORY_C = ROOT / "runtime" / "src" / "memory.c"
TRACE_CALL = "debug_server_trace_write_check"
DEFAULT_GCC = pathlib.Path(r"C:\msys64\mingw64\bin\gcc.exe")
HARNESS_COMMON_H = r"""
#ifndef PSX_MEMORY_TRACE_HARNESS_COMMON_H
#define PSX_MEMORY_TRACE_HARNESS_COMMON_H
#include <stdint.h>
#include <stddef.h>
#include "cpu_state.h"
#include "dirty_ram_interp.h"
#include "psx_bios_image.h"
#include "render_pass_plan.h"
extern int g_ls_mode;
extern int g_ls_suppress_record;
extern volatile int g_ds_recording;
extern int g_dma_exec_depth;
extern int g_dma_cur_ch;
extern uint32_t g_dma_cur_madr;
extern uint32_t g_dma_cur_bcr;
extern uint32_t g_dma_initiator_pc;
extern uint32_t g_debug_last_store_pc;
extern uint32_t g_debug_current_func_addr;
extern uint64_t s_frame_count;
extern uint32_t g_overlay_region_floor;
extern uint32_t g_text_image_lo;
extern int g_ram_read_watch_active;
extern uint64_t psx_cycle_count;
extern uint64_t g_psx_cycle_fast_limit;
extern int g_event_step_conservative;
extern int g_ls_replay_active;
extern CPUState *debug_cpu_ptr;
extern void (*g_overlay_flush_pending_cycles)(void);
extern uint32_t g_dirty_ram_exec_page_bitmap[DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS];
extern uint32_t g_dirty_ram_exec_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS];
extern uint32_t g_dirty_ram_dispatch_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS];
extern unsigned g_trace_write_calls;
extern unsigned g_parity_write_calls;
extern unsigned g_card_write_calls;
extern uint32_t g_trace_old_accum;
extern uint32_t g_trace_new_accum;
int fntrace_is_game_started(void);
int psx_get_in_exception(void);
uint32_t ls_read_hook(uint32_t addr, uint8_t width, uint32_t value);
void ls_write_hook(uint32_t addr, uint8_t width, uint32_t value);
void ds_note_read(uint32_t addr, uint8_t width);
void ds_note_write(uint32_t addr, uint8_t width);
void ds_note_dma_write(void);
void psx_devices_mmio_sync(void);
void psx_fatal_halt(const char *reason);
void psx_irq_refresh_cause_ip2(void);
int sio_card_should_hold_imask_bit7(void);
void sio_card_handoff_on_imask(uint32_t old_mask, uint32_t new_mask);
uint32_t sio_read(uint32_t addr);
void sio_write(uint32_t addr, uint32_t val);
void sio_tick(uint32_t cycles);
uint32_t dma_read(uint32_t addr);
void dma_write(uint32_t addr, uint32_t val);
void dma_write_masked(uint32_t addr, uint32_t val, uint32_t mask);
uint32_t timers_read(uint32_t addr);
void timers_write(uint32_t addr, uint32_t val);
uint32_t cdrom_read(uint32_t addr);
void cdrom_write(uint32_t addr, uint32_t val);
uint32_t gpu_read_gpuread(void);
uint32_t gpu_read_gpustat(void);
void gpu_set_gp0_source(uint32_t source);
void gpu_write_gp0(uint32_t val);
void gpu_write_gp1(uint32_t val);
uint32_t mdec_read(uint32_t addr);
void mdec_write(uint32_t addr, uint32_t val);
uint16_t spu_read(uint32_t addr);
void spu_write(uint32_t addr, uint16_t val);
void debug_server_trace_mmio_write(uint32_t addr, uint32_t val, uint8_t width);
void debug_server_trace_mmio_read(uint32_t addr, uint32_t val, uint8_t width);
void debug_server_trace_ram_read_watch(uint32_t phys, uint32_t val);
void debug_server_trace_entryint_write(uint32_t phys, uint32_t old_val,
uint32_t new_val, uint8_t width);
void parity_trace_note_write(uint32_t addr, uint32_t width, uint32_t writer_pc);
int card_data_writes_check(uint32_t phys, uint32_t value, uint8_t width);
void overlay_loader_note_code_write(void);
void overlay_loader_active_write_check(uint32_t phys, uint32_t size);
void overlay_loader_resync_validation_after_restore(void);
#endif
"""
STUBS_C = r"""
#include "harness_common.h"
#include <stdio.h>
#include <stdlib.h>
static const PsxKernelBody s_psx_bios_kernel_bodies[1] = {{0, 0, 0}};
const PsxKernelBody *psx_bios_kernel_bodies = s_psx_bios_kernel_bodies;
uint32_t psx_bios_kernel_body_count = 0;
const PsxKernelPatchRange *psx_bios_kernel_patch_ranges = 0;
uint32_t psx_bios_kernel_patch_range_count = 0;
PsxBiosImageInfo psx_bios_image = {0};
int g_ls_mode = 0;
int g_ls_suppress_record = 0;
volatile int g_ds_recording = 0;
int g_dma_exec_depth = 0;
int g_dma_cur_ch = -1;
uint32_t g_dma_cur_madr = 0;
uint32_t g_dma_cur_bcr = 0;
uint32_t g_dma_initiator_pc = 0;
uint32_t g_debug_last_store_pc = 0;
uint32_t g_debug_current_func_addr = 0;
uint64_t s_frame_count = 0;
uint32_t g_overlay_region_floor = 0x200000u;
uint32_t g_text_image_lo = 0x10000u;
int g_ram_read_watch_active = 0;
uint64_t psx_cycle_count = 0;
uint64_t g_psx_cycle_fast_limit = 0;
int g_event_step_conservative = 0;
int g_ls_replay_active = 0;
CPUState *debug_cpu_ptr = 0;
void (*g_overlay_flush_pending_cycles)(void) = 0;
uint32_t g_dirty_ram_exec_page_bitmap[DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS] = {0};
uint32_t g_dirty_ram_exec_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS] = {0};
uint32_t g_dirty_ram_dispatch_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS] = {0};
uint32_t g_dirty_ram_dispatch_seg_bitmap[PSX_CODE_SEGMENT_COUNT][DIRTY_RAM_EXEC_BITMAP_WORDS] = {{0}};
int g_psx_render_pass_active = 0;
/* These services are inactive in the ordinary RAM/scratchpad trace probes. */
void psx_icache_isc_store(uint32_t biu, uint32_t addr, uint32_t value) {
(void)biu; (void)addr; (void)value; abort();
}
void ls_shadow_record_unreplayable(void) { abort(); }
int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr,
uint32_t value, uint32_t width) {
(void)t; (void)addr; (void)value; (void)width; abort();
}
unsigned g_trace_write_calls = 0;
unsigned g_parity_write_calls = 0;
unsigned g_card_write_calls = 0;
uint32_t g_trace_old_accum = 0;
uint32_t g_trace_new_accum = 0;
int fntrace_is_game_started(void) { return 0; }
int psx_get_in_exception(void) { return 0; }
uint32_t ls_read_hook(uint32_t addr, uint8_t width, uint32_t value) {
(void)addr; (void)width; return value;
}
void ls_write_hook(uint32_t addr, uint8_t width, uint32_t value) {
(void)addr; (void)width; (void)value;
}
void ds_note_read(uint32_t addr, uint8_t width) { (void)addr; (void)width; }
void ds_note_write(uint32_t addr, uint8_t width) { (void)addr; (void)width; }
void ds_note_dma_write(void) {}
void psx_devices_mmio_sync(void) {}
void psx_fatal_halt(const char *reason) { fprintf(stderr, "%s\n", reason); abort(); }
void psx_irq_refresh_cause_ip2(void) {}
int sio_card_should_hold_imask_bit7(void) { return 0; }
void sio_card_handoff_on_imask(uint32_t old_mask, uint32_t new_mask) {
(void)old_mask; (void)new_mask;
}
uint32_t sio_read(uint32_t addr) { (void)addr; return 0; }
void sio_write(uint32_t addr, uint32_t val) { (void)addr; (void)val; }
void sio_tick(uint32_t cycles) { (void)cycles; }
uint32_t dma_read(uint32_t addr) { (void)addr; return 0; }
void dma_write(uint32_t addr, uint32_t val) { (void)addr; (void)val; }
void dma_write_masked(uint32_t addr, uint32_t val, uint32_t mask) {
(void)addr; (void)val; (void)mask;
}
uint32_t timers_read(uint32_t addr) { (void)addr; return 0; }
void timers_write(uint32_t addr, uint32_t val) { (void)addr; (void)val; }
uint32_t cdrom_read(uint32_t addr) { (void)addr; return 0; }
void cdrom_write(uint32_t addr, uint32_t val) { (void)addr; (void)val; }
uint32_t gpu_read_gpuread(void) { return 0; }
uint32_t gpu_read_gpustat(void) { return 0; }
void gpu_set_gp0_source(uint32_t source) { (void)source; }
void gpu_write_gp0(uint32_t val) { (void)val; }
void gpu_write_gp1(uint32_t val) { (void)val; }
uint32_t mdec_read(uint32_t addr) { (void)addr; return 0; }
void mdec_write(uint32_t addr, uint32_t val) { (void)addr; (void)val; }
uint16_t spu_read(uint32_t addr) { (void)addr; return 0; }
void spu_write(uint32_t addr, uint16_t val) { (void)addr; (void)val; }
void debug_server_trace_mmio_write(uint32_t addr, uint32_t val, uint8_t width) {
(void)addr; (void)val; (void)width;
}
void debug_server_trace_mmio_read(uint32_t addr, uint32_t val, uint8_t width) {
(void)addr; (void)val; (void)width;
}
void debug_server_trace_ram_read_watch(uint32_t phys, uint32_t val) {
(void)phys; (void)val;
}
void debug_server_trace_entryint_write(uint32_t phys, uint32_t old_val,
uint32_t new_val, uint8_t width) {
(void)phys; (void)old_val; (void)new_val; (void)width;
}
void parity_trace_note_write(uint32_t addr, uint32_t width, uint32_t writer_pc) {
(void)addr; (void)width; (void)writer_pc; g_parity_write_calls++;
}
int card_data_writes_check(uint32_t phys, uint32_t value, uint8_t width) {
(void)phys; (void)value; (void)width; g_card_write_calls++; return 0;
}
void overlay_loader_note_code_write(void) {}
void overlay_loader_active_write_check(uint32_t phys, uint32_t size) {
(void)phys; (void)size;
}
void overlay_loader_resync_validation_after_restore(void) {}
"""
TRACE_COUNTING_C = r"""
#include "harness_common.h"
void debug_server_trace_write_check(uint32_t phys, uint32_t old_val,
uint32_t new_val, uint8_t width) {
(void)phys;
g_trace_write_calls++;
g_trace_old_accum ^= old_val + (uint32_t)width;
g_trace_new_accum ^= new_val + ((uint32_t)width << 24);
}
"""
TRACE_EMPTY_C = r"""
#include "harness_common.h"
void debug_server_trace_write_check(uint32_t phys, uint32_t old_val,
uint32_t new_val, uint8_t width) {
(void)phys; (void)old_val; (void)new_val; (void)width;
}
"""
DRIVER_C = r"""
#include "harness_common.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
uint8_t *memory_get_ram_ptr(void);
uint8_t *memory_get_scratchpad_ptr(void);
void psx_write_word(uint32_t addr, uint32_t val);
void psx_write_half(uint32_t addr, uint16_t val);
void psx_write_byte(uint32_t addr, uint8_t val);
extern uint64_t g_guest_store_count;
static uint32_t hash_bytes(const uint8_t *bytes, const uint32_t *points,
unsigned count, uint32_t h) {
for (unsigned i = 0; i < count; i++) h = (h ^ bytes[points[i]]) * 16777619u;
return h;
}
static void seed_memory(void) {
uint8_t *ram = memory_get_ram_ptr();
uint8_t *sp = memory_get_scratchpad_ptr();
ram[0x1000u] = 0x10u; ram[0x1001u] = 0x32u;
ram[0x1002u] = 0x54u; ram[0x1003u] = 0x76u;
ram[0x1004u] = 0x98u; ram[0x1005u] = 0xBAu; ram[0x1006u] = 0xDCu;
sp[0] = 0x11u; sp[1] = 0x22u; sp[2] = 0x33u; sp[3] = 0x44u;
sp[4] = 0x55u; sp[5] = 0x66u; sp[6] = 0x77u;
}
static void do_six_writes(unsigned index) {
uint32_t mirror = (index & 3u) << 21;
uint32_t sp_segment = (index & 1u) ? 0x80000000u : 0u;
psx_write_word(0x80001000u + mirror, 0xA1B2C3D4u);
psx_write_word(0x1F800000u + sp_segment, 0x10203040u);
psx_write_half(0xA0001004u + mirror, 0xCAFEu);
psx_write_half(0x1F800004u + sp_segment, 0xBEEFu);
psx_write_byte(0x00001006u + mirror, 0x5Au);
psx_write_byte(0x1F800006u + sp_segment, 0x6Bu);
}
static int check_written_bytes(void) {
static const uint8_t expected_ram[] = {0xD4, 0xC3, 0xB2, 0xA1, 0xFE, 0xCA, 0x5A, 0};
static const uint8_t expected_sp[] = {0x40, 0x30, 0x20, 0x10, 0xEF, 0xBE, 0x6B, 0};
for (unsigned i = 0; i < sizeof(expected_ram); ++i) {
if (memory_get_ram_ptr()[0x1000u + i] != expected_ram[i] ||
memory_get_scratchpad_ptr()[i] != expected_sp[i]) return 0;
}
return memory_get_ram_ptr()[0xFFFu] == 0;
}
static uint32_t state_hash(void) {
static const uint32_t ram_points[] = {
0x1000u, 0x1001u, 0x1002u, 0x1003u, 0x1004u, 0x1005u, 0x1006u,
};
static const uint32_t sp_points[] = {
0x000u, 0x001u, 0x002u, 0x003u, 0x004u, 0x005u, 0x006u,
};
uint32_t h = 2166136261u;
h = hash_bytes(memory_get_ram_ptr(), ram_points, 7u, h);
h = hash_bytes(memory_get_scratchpad_ptr(), sp_points, 7u, h);
h ^= (uint32_t)g_guest_store_count * 3u;
h ^= g_parity_write_calls * 5u;
h ^= g_card_write_calls * 7u;
return h;
}
int main(int argc, char **argv) {
unsigned loops = 1;
if (argc == 3 && argv[1][0] == '-' && argv[1][1] == '-' &&
argv[1][2] == 'l') {
loops = (unsigned)strtoul(argv[2], 0, 0);
if (loops == 0) loops = 1;
}
seed_memory();
for (unsigned i = 0; i < loops; i++) do_six_writes(i);
if (!check_written_bytes()) {
fprintf(stderr, "incorrect RAM/scratchpad bytes or alias folding\n");
return 1;
}
printf("state=%08x trace_calls=%u parity=%u card=%u stores=%llu old=%08x new=%08x\n",
state_hash(), g_trace_write_calls, g_parity_write_calls,
g_card_write_calls, (unsigned long long)g_guest_store_count,
g_trace_old_accum, g_trace_new_accum);
return 0;
}
"""
def fail(message: str) -> None:
raise AssertionError(message)
def default_cc() -> str:
if DEFAULT_GCC.exists():
return str(DEFAULT_GCC).replace("\\", "/")
for name in ("cc", "gcc", "clang"):
path = shutil.which(name)
if path:
return path
return str(DEFAULT_GCC)
def normalize_cc(cc: str) -> str:
if "\\" in cc and len(cc) >= 3 and cc[1] == ":":
return cc.replace("\\", "/")
return cc
def source_text(path: pathlib.Path) -> str:
return path.read_text(encoding="utf-8")
def assert_guarded(memory: str) -> None:
if memory.count(TRACE_CALL) != 8:
fail("memory.c should contain one extern plus seven trace write call sites")
extern = memory.find("extern void " + TRACE_CALL)
if extern < 0:
fail("missing trace write extern")
cursor = extern + len("extern void " + TRACE_CALL)
for index in range(7):
pos = memory.find(TRACE_CALL + "(", cursor)
if pos < 0:
fail(f"missing trace write call site {index + 1}")
line_start = memory.rfind("\n", 0, pos)
guard = memory.rfind("#ifndef PSX_NO_DEBUG_TOOLS", 0, pos)
end_before = memory.rfind("#endif", 0, pos)
if guard < 0 or guard < end_before:
fail(f"trace write call site {index + 1} is not production-guarded")
if line_start - guard > 300:
fail(f"trace write call site {index + 1} guard is too far away")
cursor = pos + len(TRACE_CALL)
def assert_observers_preserved(memory: str) -> None:
required = (
("psx_write_word_raw", "static uint16_t psx_read_half_raw", (
"parity_trace_note_write(phys, 4, effective_store_pc())",
"card_data_writes_check(phys, val, 4)",
"dirty_ram_mark_kernel_write(phys)",
"text_guard_note_write(phys, val, 4)",
"overlay_watch_note_write(phys, 4)",
"ram[phys] = (uint8_t)(val)",
)),
("psx_write_half_raw", "static uint8_t psx_read_byte_raw", (
"parity_trace_note_write(phys, 2, effective_store_pc())",
"card_data_writes_check(phys, (uint32_t)val, 2)",
"dirty_ram_mark_kernel_write(phys)",
"text_guard_note_write(phys, (uint32_t)val, 2)",
"overlay_watch_note_write(phys, 2)",
"ram[phys] = (uint8_t)(val)",
)),
("psx_write_byte_raw", None, (
"parity_trace_note_write(phys, 1, effective_store_pc())",
"card_data_writes_check(phys, (uint32_t)val, 1)",
"dirty_ram_mark_kernel_write(phys)",
"text_guard_note_write(phys, (uint32_t)val, 1)",
"overlay_watch_note_write(phys, 1)",
"ram[phys] = val",
)),
)
for function, successor, tokens in required:
start = memory.find(f"static void {function}")
if start < 0:
fail(f"missing {function}")
end = len(memory) if successor is None else memory.find(successor, start + 1)
if successor is not None and end < 0:
fail(f"could not bound {function}")
body = memory[start:end]
last = -1
for token in tokens:
pos = body.find(token)
if pos < 0:
fail(f"{function} lost observer/store token: {token}")
if pos <= last:
fail(f"{function} observer/store order changed at: {token}")
last = pos
def write_fixture(work: pathlib.Path, memory_source: pathlib.Path) -> pathlib.Path:
for name, content in (("harness_common.h", HARNESS_COMMON_H),
("stubs.c", STUBS_C),
("trace_counting.c", TRACE_COUNTING_C),
("trace_empty.c", TRACE_EMPTY_C),
("driver.c", DRIVER_C)):
(work / name).write_text(content, encoding="utf-8")
return memory_source.resolve()
def build(cc: str, work: pathlib.Path, memory_source: pathlib.Path, name: str,
defines: tuple[str, ...], trace_source: str) -> pathlib.Path:
work = work.resolve()
memory_copy = write_fixture(work, memory_source)
exe = work / (name + ".exe")
cmd = [
cc,
"-std=c11",
"-O2",
"-fno-lto",
"-ffunction-sections",
"-fdata-sections",
"-I",
str(work),
"-I",
str(ROOT / "runtime" / "include"),
]
for define in defines:
cmd.append(f"-D{define}")
cmd.extend([
str(work / "driver.c"),
str(memory_copy),
str(ROOT / "runtime" / "src" / "kernel_patch_ranges.c"),
str(ROOT / "runtime" / "src" / "psx_ram_geometry.c"),
str(work / "stubs.c"),
str(work / trace_source),
"-Wl,-dead_strip" if sys.platform == "darwin" else "-Wl,--gc-sections",
"-o",
str(exe),
])
subprocess.run(cmd, cwd=work, check=True)
return exe
def parse(line: str) -> dict[str, str]:
return dict(part.split("=", 1) for part in line.split())
def run(exe: pathlib.Path, loops: int = 1) -> dict[str, str]:
cmd = [str(exe)]
if loops != 1:
cmd.extend(["--loops", str(loops)])
proc = subprocess.run(cmd, cwd=exe.parent, check=True, text=True,
stdout=subprocess.PIPE)
return parse(proc.stdout.strip())
def run_regression(cc: str) -> None:
with tempfile.TemporaryDirectory(prefix="psx-memory-trace-") as td:
root = pathlib.Path(td)
prod_dir = root / "prod"
dbg_dir = root / "debug"
prod_dir.mkdir()
dbg_dir.mkdir()
prod = build(cc, prod_dir, MEMORY_C, "current_prod",
("PSX_NO_DEBUG_TOOLS",), "trace_counting.c")
debug = build(cc, dbg_dir, MEMORY_C, "current_debug",
tuple(), "trace_counting.c")
prod_out = run(prod, 4)
debug_out = run(debug, 4)
if prod_out["trace_calls"] != "0":
fail("production memory.c still calls debug_server_trace_write_check")
if debug_out["trace_calls"] != "24":
fail("debug memory.c no longer calls all six trace write probes")
if prod_out["state"] != debug_out["state"]:
fail("production guard changed guest-visible write state")
for field in ("parity", "card", "stores"):
if prod_out[field] != debug_out[field]:
fail(f"production guard changed {field} count")
def run_with_baseline(cc: str, baseline: pathlib.Path) -> None:
if not baseline.exists():
fail(f"baseline memory source does not exist: {baseline}")
with tempfile.TemporaryDirectory(prefix="psx-memory-trace-baseline-") as td:
root = pathlib.Path(td)
old_dir = root / "old"
new_dir = root / "new"
old_dir.mkdir()
new_dir.mkdir()
old = build(cc, old_dir, baseline, "baseline_prod",
("PSX_NO_DEBUG_TOOLS",), "trace_counting.c")
new = build(cc, new_dir, MEMORY_C, "candidate_prod",
("PSX_NO_DEBUG_TOOLS",), "trace_counting.c")
old_out = run(old, 4)
new_out = run(new, 4)
if old_out["trace_calls"] != "24":
fail("baseline production source did not execute the six trace calls")
if new_out["trace_calls"] != "0":
fail("candidate production source still executes trace calls")
if old_out["state"] != new_out["state"]:
fail("candidate production changed guest-visible state vs baseline")
for field in ("parity", "card", "stores"):
if old_out[field] != new_out[field]:
fail(f"candidate production changed {field} count vs baseline")
def build_bench(cc: str, out_dir: pathlib.Path, baseline: pathlib.Path) -> None:
if not baseline.exists():
fail(f"baseline memory source does not exist: {baseline}")
out_dir.mkdir(parents=True, exist_ok=True)
old_dir = out_dir / "baseline_prod_empty"
new_dir = out_dir / "candidate_prod_empty"
old_dir.mkdir(parents=True, exist_ok=True)
new_dir.mkdir(parents=True, exist_ok=True)
old = build(cc, old_dir, baseline, "baseline_prod_empty",
("PSX_NO_DEBUG_TOOLS",), "trace_empty.c")
new = build(cc, new_dir, MEMORY_C, "candidate_prod_empty",
("PSX_NO_DEBUG_TOOLS",), "trace_empty.c")
print(f"baseline={old}")
print(f"candidate={new}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cc", default=default_cc())
parser.add_argument("--baseline-memory", type=pathlib.Path)
parser.add_argument("--build-bench", type=pathlib.Path,
help="build old/candidate empty-trace binaries here")
args = parser.parse_args()
args.cc = normalize_cc(args.cc)
memory = source_text(MEMORY_C)
assert_guarded(memory)
assert_observers_preserved(memory)
if args.build_bench:
if not args.baseline_memory:
fail("--build-bench requires --baseline-memory")
build_bench(args.cc, args.build_bench, args.baseline_memory)
return 0
run_regression(args.cc)
if args.baseline_memory:
run_with_baseline(args.cc, args.baseline_memory)
print("PASS: actual memory.c debug trace guards preserve production state")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except AssertionError as exc:
print(f"FAIL: {exc}")
sys.exit(1)
except subprocess.CalledProcessError as exc:
print(f"FAIL: command exited {exc.returncode}: {' '.join(map(str, exc.cmd))}")
sys.exit(1)
+54
View File
@@ -111,6 +111,24 @@ static void test_active_entry(CPUState*, uint32_t address) {
static void test_disabled_entry(CPUState*, uint32_t) { disabled_entry_hits++; }
static void test_unselected_entry(CPUState*, uint32_t) { unselected_entry_hits++; }
static bool filter_handles;
static bool filter_context_active;
static unsigned active_filter_hits;
static unsigned inactive_filter_hits;
static uint32_t active_filter_last;
static int test_active_filter(CPUState* cpu, uint32_t address) {
++active_filter_hits;
active_filter_last = address;
filter_context_active = psx_mod_function_entry_active() != 0;
if (!filter_handles) return 0;
cpu->gpr[2] = 42;
return 1;
}
static int test_inactive_filter(CPUState*, uint32_t) {
++inactive_filter_hits;
return 1;
}
static void test_activation_plugin(void) {
activation_calls++;
}
@@ -428,6 +446,42 @@ int main() {
disabled_entry_hits == 0 && unselected_entry_hits == 0,
"an offline rematch after netplay must arm exactly its plan's hooks");
check(psx_mod_register_function_filter_plugin(
"runtime.test-vblank", 0x80003008u, test_active_filter) == 1,
"active plan's return filter must register");
check(psx_mod_register_function_filter_plugin(
"runtime.test-vblank", 0xA0003008u, test_active_filter) == 0,
"filter aliases must not register duplicate hooks");
check(psx_mod_register_function_filter_plugin(
"runtime.test-disabled-entry", 0x80003008u, test_inactive_filter) == 1 &&
psx_mod_register_function_filter_plugin(
"runtime.unselected-entry", 0x80003008u, test_inactive_filter) == 1,
"inactive filter implementations must register without running");
entry_cpu.pc = 0x80003008u;
entry_cpu.gpr[31] = 0x80004000u;
entry_cpu.gpr[2] = 99;
check(!psx_mod_function_entry(&entry_cpu, entry_cpu.pc) && active_filter_hits == 0,
"new filters must wait for active-plan table rebuild");
mod_runtime_activate_plugins();
check(g_psx_mod_function_entry_hooks == 2 &&
!psx_mod_function_entry(&entry_cpu, entry_cpu.pc) &&
active_filter_hits == 1 && filter_context_active &&
entry_cpu.pc == 0x80003008u && entry_cpu.gpr[2] == 99,
"unhandled active filter must preserve native fallthrough");
filter_handles = true;
entry_cpu.pc = 0xA0003008u;
check(psx_mod_function_entry(&entry_cpu, entry_cpu.pc) == 1 &&
active_filter_hits == 2 && active_filter_last == 0xA0003008u &&
entry_cpu.pc == entry_cpu.gpr[31] && entry_cpu.gpr[2] == 42 &&
inactive_filter_hits == 0 && !psx_mod_function_entry_active(),
"handled alias filter must publish return registers and pc=$ra");
check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), error.c_str());
check(!psx_mod_function_entry(&entry_cpu, 0x80003008u) &&
active_filter_hits == 2 && g_psx_mod_function_entry_hooks == 0,
"clearing the active plan must also disarm return filters");
check(PSXRecompV4::mod_runtime_commit(cue_path, &error), error.c_str());
mod_runtime_activate_plugins();
ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4;
ram[0x1100] = 0; ram[0x1101] = 0;
ram[0x1102] = 1; ram[0x1103] = 0;
@@ -373,10 +373,16 @@ class ToolchainStagingTest(unittest.TestCase):
for d in (self.tools, self.include, self.bios, self.recomp, self.mingw):
os.makedirs(d, exist_ok=True)
touch(os.path.join(self.tools, 'compile_overlays.py'))
touch(os.path.join(self.tools, 'bios_module_build.py'))
touch(os.path.join(self.include, 'overlay_api.h'))
touch(os.path.join(self.include, 'overlay_dispatch_preamble.c.inc'))
touch(os.path.join(self.include, 'psx_bios_module_glue.c.inc'))
touch(os.path.join(self.include, 'psx_bios_module.h'))
touch(os.path.join(self.bios, 'SCPH1001.toml'))
touch(os.path.join(self.recomp, 'psxrecomp-game.exe'))
touch(os.path.join(self.recomp, 'psxrecomp-bios.exe'))
touch(os.path.join(self.framework, 'recompiler', 'seeds',
'phase2_ghidra_seeds.json'))
for d in ('libgcc_s_seh-1.dll', 'libstdc++-6.dll',
'libwinpthread-1.dll'):
touch(os.path.join(self.mingw, d))
@@ -34,6 +34,8 @@ assert "psx_irq_resume_context_snapshot_safe(void)" in INTERRUPTS
assert "psx_irq_resume_context_snapshot_safe_at(uint32_t resume_pc)" in INTERRUPTS
assert "psx_irq_resume_context_snapshot_pc(void)" in INTERRUPTS
assert "g_cosim_dirty_pump_site == 0" in INTERRUPTS
assert INTERRUPTS.index('if (psx_mod_function_entry_active()) return 0;') < INTERRUPTS.index('if (g_cosim_dirty_pump_site == 0)')
assert 'if (psx_mod_function_entry_active()) return;' in STATE
assert "case 1: /* transfer surface: target PC is materialized in CPUState */" in INTERRUPTS
assert "cands[n++] = psx_irq_resume_context_snapshot_pc();" in STATE
assert "cands[n++] = psx_irq_resume_context_snapshot_pc();" in REWIND
@@ -0,0 +1,59 @@
"""Execute the actual ADDI/ADDIU bodies with isolated widescreen services."""
from pathlib import Path
import subprocess
import sys
import tempfile
source = (Path(__file__).resolve().parents[1] / 'src/dirty_ram_interp.c').read_text(encoding='utf-8')
start = source.index(' case 0x08: /* ADDI rt, rs, simm')
end = source.index(' case 0x0A: /* SLTI */', start)
cases = source[start:end]
fixture = r'''
#include <assert.h>
#include <stdint.h>
#include <string.h>
typedef struct { uint32_t gpr[32]; } CPUState;
static int margin;
static int psx_ws_activation_margin(void) { return margin; }
static int psx_ws_is_cull_bias_site(uint32_t pc) { return pc == 4; }
static int psx_ws_is_cull_bias_lower_site(uint32_t pc) { return pc == 8; }
static int psx_ws_angle_site(uint32_t pc, uint32_t insn, uint32_t *out) { return 0; }
static int psx_ws_is_signed_x_bound_site(uint32_t pc, uint32_t insn) { return 0; }
static int psx_ws_screen_x_bound(int imm) { assert(0); return imm; }
#define psx_pgxp_alu(...) ((void)0)
static int execute(CPUState *cpu, uint32_t pc, uint32_t insn) {
unsigned rs = (insn >> 21) & 31, rt = (insn >> 16) & 31;
int32_t simm = (int16_t)insn;
switch (insn >> 26) {
@CASES@
default: assert(0); return 1;
}
}
int main(void) {
const int margins[] = {0, 85, 138, 234};
const uint32_t cameras[] = {0, 51, 1024, 0xffff, 0xffffffff};
for (unsigned op = 8; op <= 9; op++)
for (unsigned m = 0; m < 4; m++)
for (unsigned c = 0; c < 5; c++)
for (unsigned kind = 0; kind < 3; kind++) {
CPUState cpu, expected;
for (unsigned r = 0; r < 32; r++) cpu.gpr[r] = r * 7919;
cpu.gpr[4] = cameras[c];
expected = cpu;
margin = margins[m];
int imm = kind == 1 ? 368 : -48;
expected.gpr[2] = cameras[c] + (uint32_t)imm;
if (kind == 1) expected.gpr[2] += margin;
if (kind == 2) expected.gpr[2] -= margin;
assert(execute(&cpu, kind * 4, (op << 26) | (4 << 21) | (2 << 16) | (uint16_t)imm) == 0);
assert(memcmp(&cpu, &expected, sizeof cpu) == 0);
}
return 0;
}
'''.replace('@CASES@', cases)
with tempfile.TemporaryDirectory(prefix='ws-activation-') as directory:
path = Path(directory)
(path / 'test.c').write_text(fixture, encoding='utf-8')
subprocess.run([sys.argv[1], '-std=c99', str(path / 'test.c'), '-o', str(path / 'test.exe')], check=True)
subprocess.run([str(path / 'test.exe')], check=True)
print('activation bias ADDI/ADDIU: 4:3 identity, both sides, unrelated sites and register preservation PASS')
+29
View File
@@ -0,0 +1,29 @@
#ifdef NDEBUG
#undef NDEBUG
#endif
#include <assert.h>
#include "ws_screen_mask.h"
#include "ws_hud_anchor.h"
int main(void) {
int32_t x[4]={-96,160,-96,68},y[4]={0,0,240,240};
WsScreenMaskBand b;
for(int margin=0;margin<16000;margin+=53) {
int result=ws_screen_mask_band(x,y,320,240,margin,&b);
assert(result==(margin>96));
if(result) assert(b.x==-margin && b.x+b.w==-96 && b.y==0 && b.h==240);
}
x[0]=160;x[1]=416;x[2]=308;x[3]=416;
assert(ws_screen_mask_band(x,y,320,240,694,&b));
assert(b.x==416 && b.w==598);
x[3]=415; assert(!ws_screen_mask_band(x,y,320,240,694,&b));
x[3]=416;y[3]=239;assert(!ws_screen_mask_band(x,y,320,240,694,&b));
/* Same packet address with different geometry/colour must lose its tag. */
WsHudAnchorTag tags[WS_HUD_ANCHOR_TABLE_SIZE]={0};
uint32_t words[5]={0x2a7b7b7b,0x0000ffa0,0xa0,0xf0ffa0,0xf00044};
WsPrepassPacketGuard guard=ws_prepass_packet_guard(words,5);
ws_hud_anchor_insert(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,0,&guard,20);
assert(ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,words,5,20,0));
words[2]++;assert(!ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,words,5,20,0));
words[2]--;ws_hud_anchor_clear(tags,WS_HUD_ANCHOR_TABLE_SIZE);
assert(!ws_hud_anchor_lookup(tags,WS_HUD_ANCHOR_TABLE_SIZE,0xa68b4,words,5,20,0));
}
+100
View File
@@ -0,0 +1,100 @@
#ifdef NDEBUG
#undef NDEBUG
#endif
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ws_view_anchor.h"
#include "../src/gpu_sw_renderer.c"
int g_ws_bd_stretch_on, g_ws_bd_stretch_pct;
int psx_ws_prim_in_backdrop(void) { return 0; }
int g_psx_vram_dirty_tracking;
void gpu_vram_dirty_mark_row_impl(uint32_t y) { (void)y; }
void gpu_vram_dirty_mark_rect(int x, int y, int w, int h) {
(void)x; (void)y; (void)w; (void)h;
}
void gpu_vram_dirty_mark_all(void) {}
static uint16_t native[1024 * 512], reference[1024 * 512];
static void check(int cx, int lo, int hi, int left, int right, int shift) {
WsViewAnchor v = ws_view_anchor(53, cx, lo, hi);
assert(v.left == left && v.right == right && v.shift == shift);
assert(v.left + v.right + v.pad_left + v.pad_right == 106);
assert(53 + v.shift - v.left == v.pad_left);
assert(53 + v.shift + 320 + v.right == 426 - v.pad_right);
}
static void draw(int shift, int pad_left, int pad_right) {
sw_wide_clear(0, 0, 240, 0);
memset(native, 0, sizeof(native));
sw_wide_set_view(1, shift, pad_left, pad_right);
/* Every column has a distinct world color. Include both potential reveal
* regions so the renderer, rather than the fixture, decides visibility. */
for (int x = -106; x < 426; x++)
sw_draw_flat_rect(x, 40, 1, 1, (uint16_t)(0x4000 | ((x + 107) & 1023)));
/* Existing left/center/right HUD placements, independent of world origin. */
sw_wide_set_view(1, 0, 0, 0);
sw_draw_flat_rect(19 - 53, 10, 3, 1, 31);
sw_draw_flat_rect(160, 10, 3, 1, 31);
sw_draw_flat_rect(300 + 53, 10, 3, 1, 31);
}
int main(void) {
/* Same framing at 32:9 / 64:9 and beyond; finite rooms keep symmetric
* padding while a full stage transfers the reveal to the available side. */
const int extras[] = {267, 693, 1600};
for (unsigned i = 0; i < sizeof extras / sizeof extras[0]; ++i) {
int e = extras[i];
WsViewAnchor left = ws_view_anchor(e,0,0,5120);
WsViewAnchor right = ws_view_anchor(e,5120,0,5120);
WsViewAnchor room = ws_view_anchor(e,200,200,200);
assert(left.left==0 && left.right==2*e && left.shift==-e);
assert(right.left==2*e && right.right==0 && right.shift==e);
assert(room.left==0 && room.right==0 && room.shift==0);
assert(room.pad_left==e && room.pad_right==e);
}
check(0, 0, 1000, 0, 106, -53);
check(20, 0, 1000, 20, 86, -33);
check(500, 0, 1000, 53, 53, 0);
check(980, 0, 1000, 86, 20, 33);
check(1000, 0, 1000, 106, 0, 53);
check(200, 200, 200, 0, 0, 0);
check(200, 200, 249, 0, 49, -25);
check(249, 200, 249, 49, 0, 24);
/* Continuous, monotonic origin for every pixel of a full-room traversal. */
int previous = -53;
for (int x = 0; x <= 1000; x++) {
WsViewAnchor v = ws_view_anchor(53, x, 0, 1000);
assert(v.shift >= previous && v.shift <= previous + 1);
previous = v.shift;
}
assert(ws_view_anchor(53, -1, 0, 1000).shift == 0);
assert(ws_view_anchor(53, 500, 1000, 0).shift == 0);
assert(ws_view_anchor(0, 0, 0, 1000).left == 0);
sw_renderer_init(native);
sw_set_draw_area(0, 0, 319, 239);
sw_wide_configure(426, 53);
sw_wide_set_target(0);
draw(0, 0, 0);
memcpy(reference, native, sizeof(native));
for (int shift = -53; shift <= 53; shift++) {
draw(shift, 0, 0);
assert(memcmp(reference, native, sizeof(native)) == 0);
assert(g_wide_cur[40 * 426] == (uint16_t)(0x4000 | (54 - shift)));
assert(g_wide_cur[40 * 426 + 425] == (uint16_t)(0x4000 | (479 - shift)));
assert(g_wide_cur[10 * 426 + 19] == 31);
assert(g_wide_cur[10 * 426 + 213] == 31);
assert(g_wide_cur[10 * 426 + 406] == 31);
}
draw(-25, 28, 29);
for (int x = 0; x < 426; x++)
assert((g_wide_cur[40 * 426 + x] != 0) == (x >= 28 && x < 397));
assert(g_wide_cur[10 * 426 + 19] == 31); /* HUD can occupy padding. */
sw_wide_set_view(0, 77, 28, 29);
assert(wide_dx() == 53 && rt_wide().cx1 == 0 && rt_wide().cx2 == 425);
puts("ws_view_anchor: geometry, edge pixels, fixed HUD, narrow-room clip, canonical identity PASS");
return 0;
}
+130
View File
@@ -0,0 +1,130 @@
/* Reuse the executable GPU fixture, including its existing HUD regression. */
#define main textured_dot_regression_main
#include "test_gpu_textured_dot_nw_shift_exec.c"
#undef main
static void half_at(uint32_t addr, uint16_t value) {
memcpy((uint8_t *)test_ram + (addr & 0x1fffffu), &value, 2);
}
int main(void) {
assert(textured_dot_regression_main() == 0);
reset_gpu_state_for_test();
configure_native_wide_16_9();
const uint32_t lower_sites[] = {0x80029fd0u};
assert(!psx_ws_is_cull_bias_lower_site(lower_sites[0]));
gpu_ws_set_bias_lower_cull_sites(lower_sites, 1);
assert(psx_ws_is_cull_bias_lower_site(0xa0029fd0u));
assert(!psx_ws_is_cull_bias_lower_site(0x80029fd4u));
gpu_ws_set_bias_lower_cull_sites(NULL, 0);
assert(!psx_ws_is_cull_bias_lower_site(lower_sites[0]));
gpu_ws_set_view_anchor(0x80097202u, 0x80097216u, 0x80097214u, 0x800971f8u);
half_at(0x80097214u, 5120);
half_at(0x80097216u, 0);
for (unsigned i = 0; i < 3; i++) {
uint32_t b = 0x971f8u + i * 0x54;
((uint8_t *)test_ram)[b] = 1;
((uint8_t *)test_ram)[b + 0x52] = 255;
((uint8_t *)test_ram)[b + 0x4e] = 25;
}
half_at(0x80097202u, 50);
half_at(0x80097256u, 25);
half_at(0x800972aau, 12);
g_mmx6_freshfix = 0; /* This fixture isolates geometry from tile decoding. */
gpu_ws_bg2d_begin_view_layer(0, 0x80010000u, 6);
assert(psx_ws_bg2d_startcol(3, 63) == 63);
assert(psx_ws_bg2d_cols(21) == 29);
gpu_ws_bg2d_end_view_layer(0, 0x80011000u);
gpu_ws_bg2d_begin_view_layer(1, 0x80011000u, 6);
assert(psx_ws_bg2d_startcol(1, 63) == 63);
assert(psx_ws_bg2d_cols(21) == 29);
gpu_ws_bg2d_end_view_layer(1, 0x80012000u);
gpu_ws_bg2d_begin_view_layer(2, 0x80012000u, 6);
assert(psx_ws_bg2d_startcol(0, 63) == 63);
assert(psx_ws_bg2d_cols(21) == 28);
gpu_ws_bg2d_end_view_layer(2, 0x80013000u);
gp0_cmd_source_addr = 0x12004;
WsViewAnchor bg = ws_view_packet();
assert(bg.shift == -41 && bg.left == 12 && bg.right == 94);
gp0_cmd_source_addr = 0x10004;
assert(ws_view_packet().shift == -3);
/* Transient camera writes do not change geometry after renderer setup. */
half_at(0x80097202u, 0);
assert(ws_view_current().left == 50);
s_frame_count++;
gpu_ws_bg2d_begin_view_layer(0, 0x80014000u, 6);
assert(psx_ws_bg2d_startcol(0, 63) == 0);
assert(psx_ws_bg2d_cols(21) == 28);
assert(ws_view_current().left == 0 && ws_view_current().right == 106);
/* A camera lock inside continuous geometry must not become a new scene
* edge. An explicit scene bound still anchors at the real outer edges. */
half_at(0x80097202u, 1968); half_at(0x80097216u, 1968);
gpu_ws_set_view_bounds_override(1, 0, 5120);
ws_view_sample();
assert(ws_view_current().left == 53 && ws_view_current().shift == 0);
half_at(0x80097202u, 0); ws_view_sample();
assert(ws_view_current().left == 0 && ws_view_current().right == 106);
gpu_ws_set_view_bounds_override(0, 0, 5120);
half_at(0x80097202u, 1968); ws_view_sample();
assert(ws_view_current().left == 0 && ws_view_current().right == 106);
half_at(0x80097216u, 0); half_at(0x80097202u, 0);
/* With the option disabled every original BG helper remains centered. */
gpu_ws_set_view_anchor(0, 0, 0, 0);
assert(psx_ws_bg2d_cols(21) == 29);
assert(psx_ws_bg2d_startx(0) == -64);
assert(psx_ws_bg2d_stream_right(336) == 400);
ws_mode = 0;
assert(psx_ws_bg2d_cols(21) == 21);
assert(psx_ws_bg2d_startx(0) == 0);
/* The opt-in host arena keeps guest loops and the ring native, while
* its packet coordinates remain signed 16-bit beyond PS1's +/-1024. */
configure_native_wide_16_9();
gpu_ws_bg2d_set_host_arena(0x80100000u, 64u); /* mapped fixture memory */
half_at(0x10001c, 0x4247u); half_at(0x10001e, 0x5836u);
gp0_cmd_source_addr = 0x00100004u;
int32_t px, py;
parse_vertex(pack_vertex(1400, 20), &px, &py);
assert(px == 1400 && py == 20);
/* Reflection reverses the exact 16 texels, without reading the adjacent
* tile's first column. Its metadata is confined to the registered arena. */
half_at(0x10001e, 0xd836u);
gp0_cmd_buf[0]=0x7d808080u;
gp0_cmd_buf[1]=pack_vertex(400,20);
gp0_cmd_buf[2]=0x79804000u;
gp0_exec_textured_16x16();
assert(last_scaled_rect.calls==1 && last_scaled_rect.x==400);
assert(last_scaled_rect.w==16 && last_scaled_rect.h==16);
assert(last_scaled_rect.u0==15 && last_scaled_rect.u1==-1);
ws_mode = 0;
gp0_cmd_buf[1]=pack_vertex(20,20);
int before_resize_calls=last_scaled_rect.calls;
gp0_exec_textured_16x16();
assert(last_scaled_rect.calls==before_resize_calls+1 && last_scaled_rect.x==20);
configure_native_wide_16_9();
half_at(0x10001e, 0x5836u);
/* New metadata packs a bank ID beside a signed view shift; old saved
* packet metadata remains supported, with no accidental sign extension. */
half_at(0x10001c, 0x4248u);
half_at(0x100010, (uint16_t)-53); half_at(0x100012, 0x6001u);
assert(ws_view_packet().shift == -53);
half_at(0x10001c, 0x4247u); half_at(0x100012, 0xffffu);
assert(ws_view_packet().shift == -53);
gp0_cmd_source_addr = 0x00010004u;
parse_vertex(pack_vertex(1400, 20), &px, &py);
assert(px == -648 && py == 20); /* Ordinary hardware packet unchanged. */
gpu_ws_tag_world_primitive(0x80010000u, 1);
parse_vertex(pack_vertex(1400, 20), &px, &py);
assert(px == 1400 && py == 20);
parse_vertex(pack_vertex(-1400, 20), &px, &py);
assert(px == -1400 && py == 20);
gpu_ws_tag_world_primitive(0x80010000u, 0);
parse_vertex(pack_vertex(1400, 20), &px, &py);
assert(px == -648 && py == 20);
assert(psx_ws_bg2d_cols(21) == 21);
assert(psx_ws_bg2d_startcol(3, 63) == 3);
assert(psx_ws_bg2d_startx(-5) == -5);
assert(psx_ws_bg2d_stream_left(100) == 100);
assert(psx_ws_bg2d_stream_right(436) == 436);
gpu_ws_bg2d_set_host_arena(0, 0);
puts("ws_view_anchor_gpu: sampled camera, per-layer coverage, packet origin, disabled identity PASS");
return 0;
}
+8 -1
View File
@@ -5,11 +5,12 @@
uint64_t s_frame_count;
static uint32_t state_word;
static int world, calls, fmv, hold;
static int world, calls, fmv, hold, native_effect;
uint32_t psx_read_word(uint32_t address) { (void)address; return state_word; }
int mdec_recently_active(uint32_t frames) { (void)frames; return fmv; }
static int world_scene(void) { ++calls; return world; }
static int retained_scene(void) { return hold; }
static int native_scene(void) { return native_effect; }
static int native_43(void) { ++s_frame_count; return gpu_ws_present_native_43(); }
int main(void) {
@@ -38,6 +39,12 @@ int main(void) {
assert(native_43() == 1); /* 24-bit FMV veto */
display_depth = 0;
assert(native_43() == 0);
gpu_ws_set_native_scene_predicate(native_scene);
native_effect=1; assert(native_43()==1); /* Native attack overrides world. */
native_effect=0; assert(native_43()==0); /* Recovery restores adaptive. */
native_effect=1; ws_mode=0; assert(native_43()==0); /* Off stays inert. */
ws_mode=2; gpu_ws_set_native_scene_predicate(NULL);
assert(native_43()==0); /* No policy left behind after unregister. */
psx_mod_set_retained_scene_predicate(retained_scene);
hold = 0;
assert(native_43() == 0);
File diff suppressed because it is too large Load Diff
+867
View File
@@ -0,0 +1,867 @@
#!/usr/bin/env python3
"""Launch a benchmark child under a process-scoped host CPU budget.
Windows uses a Job Object hard CPU-rate cap. The cap is applied before the child
thread is resumed, so the target does not do meaningful work outside the budget.
Other platforms may run unrestricted, but CPU throttling fails explicitly.
"""
from __future__ import annotations
import argparse
import ctypes
import ctypes.wintypes
import json
import math
import os
import platform
import shutil
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
class PerfHostError(RuntimeError):
pass
class UnsupportedThrottle(PerfHostError):
pass
@dataclass
class LaunchResult:
pid: int
exit_code: int | None
timed_out: bool
wall_seconds: float
cpu_user_seconds: float | None
cpu_kernel_seconds: float | None
cpu_total_seconds: float | None
def parse_affinity(text: str | None) -> int | None:
if text is None or text == "":
return None
text = text.strip()
if not text:
return None
if "," in text:
mask = 0
for part in text.split(","):
part = part.strip()
if not part:
raise ValueError("empty CPU index in affinity list")
idx = int(part, 10)
if idx < 0:
raise ValueError("negative CPU index in affinity list")
mask |= 1 << idx
if mask == 0:
raise ValueError("affinity mask may not be zero")
return mask
mask = int(text, 0)
if mask <= 0:
raise ValueError("affinity mask may not be zero")
return mask
def validate_cpu_percent(value: int | None) -> int | None:
if value is None:
return None
if value < 1 or value > 100:
raise ValueError("--cpu-percent must be in 1..100")
return value
def validate_core_percent(value: float | None) -> float | None:
if value is None:
return None
if not math.isfinite(value) or value <= 0.0 or value > 100.0:
raise ValueError("--core-percent must be finite and in (0, 100]")
return value
def topology_report() -> dict[str, Any]:
affinity = None
if hasattr(os, "sched_getaffinity"):
try:
affinity = sorted(os.sched_getaffinity(0)) # type: ignore[attr-defined]
except OSError:
affinity = None
return {
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
"logical_cpu_count": os.cpu_count(),
"process_affinity": affinity,
}
def _filetime_seconds(ft: Any) -> float:
ticks = (int(ft.dwHighDateTime) << 32) | int(ft.dwLowDateTime)
return ticks / 10_000_000.0
if os.name == "nt":
wintypes = ctypes.wintypes
CREATE_SUSPENDED = 0x00000004
CREATE_NO_WINDOW = 0x08000000
STARTF_USESHOWWINDOW = 0x00000001
STARTF_USESTDHANDLES = 0x00000100
SW_HIDE = 0
INFINITE = 0xFFFFFFFF
TERMINATE_WAIT_MS = 5000
WAIT_OBJECT_0 = 0
WAIT_TIMEOUT = 0x00000102
JobObjectExtendedLimitInformation = 9
JobObjectCpuRateControlInformation = 15
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
JOB_OBJECT_CPU_RATE_CONTROL_ENABLE = 0x1
JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP = 0x4
class STARTUPINFOW(ctypes.Structure):
_fields_ = [
("cb", wintypes.DWORD),
("lpReserved", wintypes.LPWSTR),
("lpDesktop", wintypes.LPWSTR),
("lpTitle", wintypes.LPWSTR),
("dwX", wintypes.DWORD),
("dwY", wintypes.DWORD),
("dwXSize", wintypes.DWORD),
("dwYSize", wintypes.DWORD),
("dwXCountChars", wintypes.DWORD),
("dwYCountChars", wintypes.DWORD),
("dwFillAttribute", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("wShowWindow", wintypes.WORD),
("cbReserved2", wintypes.WORD),
("lpReserved2", ctypes.POINTER(ctypes.c_byte)),
("hStdInput", wintypes.HANDLE),
("hStdOutput", wintypes.HANDLE),
("hStdError", wintypes.HANDLE),
]
class PROCESS_INFORMATION(ctypes.Structure):
_fields_ = [
("hProcess", wintypes.HANDLE),
("hThread", wintypes.HANDLE),
("dwProcessId", wintypes.DWORD),
("dwThreadId", wintypes.DWORD),
]
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", ctypes.c_longlong),
("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD),
]
class IO_COUNTERS(ctypes.Structure):
_fields_ = [
("ReadOperationCount", ctypes.c_ulonglong),
("WriteOperationCount", ctypes.c_ulonglong),
("OtherOperationCount", ctypes.c_ulonglong),
("ReadTransferCount", ctypes.c_ulonglong),
("WriteTransferCount", ctypes.c_ulonglong),
("OtherTransferCount", ctypes.c_ulonglong),
]
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
("IoInfo", IO_COUNTERS),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t),
]
class JOBOBJECT_CPU_RATE_CONTROL_INFORMATION(ctypes.Structure):
_fields_ = [("ControlFlags", wintypes.DWORD), ("CpuRate", wintypes.DWORD)]
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateProcessW.argtypes = [
wintypes.LPCWSTR,
wintypes.LPWSTR,
wintypes.LPVOID,
wintypes.LPVOID,
wintypes.BOOL,
wintypes.DWORD,
wintypes.LPVOID,
wintypes.LPCWSTR,
ctypes.POINTER(STARTUPINFOW),
ctypes.POINTER(PROCESS_INFORMATION),
]
kernel32.CreateProcessW.restype = wintypes.BOOL
kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
kernel32.SetInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
wintypes.LPVOID,
wintypes.DWORD,
]
kernel32.SetInformationJobObject.restype = wintypes.BOOL
kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
kernel32.SetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.c_size_t]
kernel32.SetProcessAffinityMask.restype = wintypes.BOOL
kernel32.GetProcessAffinityMask.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(ctypes.c_size_t),
ctypes.POINTER(ctypes.c_size_t),
]
kernel32.GetProcessAffinityMask.restype = wintypes.BOOL
kernel32.GetCurrentProcess.argtypes = []
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
kernel32.IsProcessInJob.argtypes = [
wintypes.HANDLE,
wintypes.HANDLE,
ctypes.POINTER(wintypes.BOOL),
]
kernel32.IsProcessInJob.restype = wintypes.BOOL
kernel32.QueryInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
wintypes.LPVOID,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
]
kernel32.QueryInformationJobObject.restype = wintypes.BOOL
kernel32.ResumeThread.argtypes = [wintypes.HANDLE]
kernel32.ResumeThread.restype = wintypes.DWORD
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
kernel32.WaitForSingleObject.restype = wintypes.DWORD
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
kernel32.GetProcessTimes.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
ctypes.POINTER(wintypes.FILETIME),
]
kernel32.GetProcessTimes.restype = wintypes.BOOL
kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
kernel32.TerminateJobObject.restype = wintypes.BOOL
kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
kernel32.TerminateProcess.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
def _winerr(prefix: str) -> PerfHostError:
return PerfHostError(f"{prefix}: WinError {ctypes.get_last_error()}")
def validate_timeout(value: float | None) -> float | None:
if value is None:
return None
if not math.isfinite(value) or value < 0.0:
raise ValueError("--timeout must be a finite non-negative number")
if value * 1000.0 > 0xFFFFFFFE:
raise ValueError("--timeout is too large for the Windows wait API")
return value
def affinity_scaled_cpu_rate_units(
cpu_percent: float | None,
affinity_mask: int | None,
logical_cpu_count: int | None,
) -> int | None:
if cpu_percent is None or not logical_cpu_count:
return None
affinity_cpus = affinity_mask.bit_count() if affinity_mask is not None else logical_cpu_count
if affinity_cpus <= 0:
return None
units = math.ceil(cpu_percent * affinity_cpus * 100 / logical_cpu_count)
return min(10000, max(1, units))
def one_core_cpu_rate_units(core_percent: float | None, logical_cpu_count: int | None) -> int | None:
if core_percent is None or not logical_cpu_count:
return None
units = math.ceil(core_percent * 100 / logical_cpu_count)
return min(10000, max(1, units))
def host_cpu_rate_units(cpu_percent: int | None) -> int | None:
if cpu_percent is None:
return None
return min(10000, max(1, cpu_percent * 100))
def _path_text(path: Path | None) -> str | None:
return str(path) if path is not None else None
def _set_job_kill_on_close(job: int) -> None:
info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
ok = kernel32.SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
ctypes.byref(info),
ctypes.sizeof(info),
)
if not ok:
raise _winerr("SetInformationJobObject(KILL_ON_JOB_CLOSE) failed")
def _set_job_cpu_rate(job: int, cpu_rate: int) -> int:
info = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION()
info.ControlFlags = JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP
info.CpuRate = cpu_rate
ok = kernel32.SetInformationJobObject(
job,
JobObjectCpuRateControlInformation,
ctypes.byref(info),
ctypes.sizeof(info),
)
if not ok:
raise _winerr("SetInformationJobObject(CPU_RATE_CONTROL) failed")
return cpu_rate
def _resolve_application(argv: Sequence[str]) -> str | None:
if not argv:
raise ValueError("missing executable after --")
exe = argv[0]
if any(ch in exe for ch in "\\/:"):
return exe
found = shutil.which(exe)
return found
def _query_windows_affinity(process: int) -> dict[str, int] | None:
if not hasattr(kernel32, "GetProcessAffinityMask"):
return None
proc_mask = ctypes.c_size_t()
sys_mask = ctypes.c_size_t()
ok = kernel32.GetProcessAffinityMask(
process, ctypes.byref(proc_mask), ctypes.byref(sys_mask)
)
if not ok:
return None
return {"process_mask": int(proc_mask.value), "system_mask": int(sys_mask.value)}
def _validate_windows_affinity_mask(affinity_mask: int | None) -> None:
if affinity_mask is None:
return
size_t_bits = ctypes.sizeof(ctypes.c_size_t) * 8
if affinity_mask.bit_length() > size_t_bits:
raise ValueError(
f"affinity mask 0x{affinity_mask:x} exceeds {size_t_bits}-bit size_t"
)
current_process = kernel32.GetCurrentProcess() if hasattr(kernel32, "GetCurrentProcess") else None
current_affinity = (
_query_windows_affinity(current_process) if current_process is not None else None
)
system_mask = current_affinity["system_mask"] if current_affinity else None
if system_mask is not None and affinity_mask & ~system_mask:
raise ValueError(
f"affinity mask 0x{affinity_mask:x} is outside system mask 0x{system_mask:x}"
)
def _is_process_in_job(process: int, job: int | None = None) -> bool | None:
if not hasattr(kernel32, "IsProcessInJob"):
return None
result = wintypes.BOOL()
ok = kernel32.IsProcessInJob(process, job, ctypes.byref(result))
if not ok:
return None
return bool(result.value)
def _query_job_cpu_rate(job: int) -> dict[str, int] | None:
if not hasattr(kernel32, "QueryInformationJobObject"):
return None
info = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION()
returned = wintypes.DWORD()
ok = kernel32.QueryInformationJobObject(
job,
JobObjectCpuRateControlInformation,
ctypes.byref(info),
ctypes.sizeof(info),
ctypes.byref(returned),
)
if not ok:
return None
return {"control_flags": int(info.ControlFlags), "cpu_rate_units": int(info.CpuRate)}
def _open_windows_stdio(
stdout_log: Path | None,
stderr_log: Path | None,
) -> tuple[list[Any], dict[str, Any]]:
files: list[Any] = []
info = {
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
"stdio_mode": "log-files" if stdout_log or stderr_log else "detached",
}
if not stdout_log and not stderr_log:
return files, info
import msvcrt
stdin = open(os.devnull, "rb")
files.append(stdin)
os.set_handle_inheritable(msvcrt.get_osfhandle(stdin.fileno()), True)
if stdout_log:
stdout_log.parent.mkdir(parents=True, exist_ok=True)
stdout = open(stdout_log, "wb")
files.append(stdout)
else:
stdout = open(os.devnull, "wb")
files.append(stdout)
os.set_handle_inheritable(msvcrt.get_osfhandle(stdout.fileno()), True)
if stderr_log:
stderr_log.parent.mkdir(parents=True, exist_ok=True)
mode = "ab" if stdout_log and stderr_log == stdout_log else "wb"
stderr = open(stderr_log, mode)
files.append(stderr)
else:
stderr = open(os.devnull, "wb")
files.append(stderr)
os.set_handle_inheritable(msvcrt.get_osfhandle(stderr.fileno()), True)
info["stdin_handle"] = "NUL"
return files, info
def launch_windows(
argv: Sequence[str],
cpu_percent: int | None,
affinity_mask: int | None,
timeout: float | None,
cwd: str | None = None,
stdout_log: Path | None = None,
stderr_log: Path | None = None,
cpu_rate_units: int | None = None,
cpu_rate_mode: str = "host_percent",
core_percent: float | None = None,
) -> tuple[LaunchResult, dict[str, Any]]:
if not argv:
raise ValueError("missing executable after --")
_validate_windows_affinity_mask(affinity_mask)
si = STARTUPINFOW()
si.cb = ctypes.sizeof(si)
si.dwFlags = STARTF_USESHOWWINDOW
si.wShowWindow = SW_HIDE
stdio_files, stdio_info = _open_windows_stdio(stdout_log, stderr_log)
if stdio_files:
import msvcrt
si.dwFlags |= STARTF_USESTDHANDLES
si.hStdInput = msvcrt.get_osfhandle(stdio_files[0].fileno())
si.hStdOutput = msvcrt.get_osfhandle(stdio_files[1].fileno())
si.hStdError = msvcrt.get_osfhandle(stdio_files[2].fileno())
pi = PROCESS_INFORMATION()
cmdline = ctypes.create_unicode_buffer(subprocess.list2cmdline(list(argv)))
app = _resolve_application(argv)
creation_flags = CREATE_SUSPENDED | CREATE_NO_WINDOW
start = time.perf_counter()
try:
ok = kernel32.CreateProcessW(
app,
cmdline,
None,
None,
bool(stdio_files),
creation_flags,
None,
cwd,
ctypes.byref(si),
ctypes.byref(pi),
)
if not ok:
raise _winerr("CreateProcessW failed")
except BaseException:
for f in stdio_files:
f.close()
raise
job = None
logical_cpu_count = os.cpu_count()
if cpu_rate_units is None:
cpu_rate_units = host_cpu_rate_units(cpu_percent)
current_process = kernel32.GetCurrentProcess() if hasattr(kernel32, "GetCurrentProcess") else None
parent_in_job = (
_is_process_in_job(current_process, None) if current_process is not None else None
)
child_in_job_before_assign = _is_process_in_job(pi.hProcess, None)
effective: dict[str, Any] = {
"platform": "windows",
"throttle_supported": True,
"cpu_rate_mode": cpu_rate_mode,
"core_percent": core_percent,
"job_cpu_rate_units": None,
"job_cpu_rate_denominator": 10000,
"job_cpu_rate_readback": None,
"job_denominator_note": (
"Windows Job Object CpuRate units; 10000 is 100 percent of the "
"OS job CPU-rate denominator, not a single-thread slowdown model. "
"Record affinity separately and calibrate with a one-worker "
"CPU-bound witness on the target host."
),
"parent_process_in_job": parent_in_job,
"child_process_in_job_before_assign": child_in_job_before_assign,
"nested_job_note": (
"If a parent job already has CPU rate control, Windows applies "
"nested job rates relative to that parent. This report records "
"whether the host and child were already in a job before assignment."
),
"affinity_cpu_count": affinity_mask.bit_count() if affinity_mask is not None else None,
"affinity_scaled_job_cpu_rate_units": affinity_scaled_cpu_rate_units(
cpu_percent, affinity_mask, logical_cpu_count
),
"core_percent_job_cpu_rate_units": one_core_cpu_rate_units(
core_percent, logical_cpu_count
),
"affinity_scaled_note": (
"Informational only: raw Job Object CpuRate units that would make "
"--cpu-percent apply to the requested affinity set as a fraction "
"of this host's logical CPU count. --core-percent instead applies "
"a one-logical-CPU-equivalent conversion, independent of affinity."
),
"affinity_mask": affinity_mask,
"affinity_applied": False,
"affinity_actual": None,
**stdio_info,
}
timed_out = False
exit_code: int | None = None
user_s = kernel_s = total_s = None
try:
job = kernel32.CreateJobObjectW(None, None)
if not job:
raise _winerr("CreateJobObjectW failed")
_set_job_kill_on_close(job)
if cpu_rate_units is not None:
effective["job_cpu_rate_units"] = _set_job_cpu_rate(job, cpu_rate_units)
effective["job_cpu_rate_readback"] = _query_job_cpu_rate(job)
if affinity_mask is not None:
if not kernel32.SetProcessAffinityMask(pi.hProcess, affinity_mask):
raise _winerr("SetProcessAffinityMask failed")
effective["affinity_applied"] = True
effective["affinity_actual"] = _query_windows_affinity(pi.hProcess)
if not kernel32.AssignProcessToJobObject(job, pi.hProcess):
raise _winerr("AssignProcessToJobObject failed")
if kernel32.ResumeThread(pi.hThread) == 0xFFFFFFFF:
raise _winerr("ResumeThread failed")
wait_ms = INFINITE if timeout is None else max(0, int(timeout * 1000))
wait = kernel32.WaitForSingleObject(pi.hProcess, wait_ms)
if wait == WAIT_TIMEOUT:
timed_out = True
kernel32.TerminateJobObject(job, 1)
done = kernel32.WaitForSingleObject(pi.hProcess, TERMINATE_WAIT_MS)
if done == WAIT_TIMEOUT:
raise PerfHostError("child did not exit after timeout termination")
elif wait != WAIT_OBJECT_0:
raise _winerr("WaitForSingleObject failed")
code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code)):
raise _winerr("GetExitCodeProcess failed")
exit_code = int(code.value)
creation = wintypes.FILETIME()
exit_ft = wintypes.FILETIME()
kernel_ft = wintypes.FILETIME()
user_ft = wintypes.FILETIME()
if kernel32.GetProcessTimes(
pi.hProcess,
ctypes.byref(creation),
ctypes.byref(exit_ft),
ctypes.byref(kernel_ft),
ctypes.byref(user_ft),
):
user_s = _filetime_seconds(user_ft)
kernel_s = _filetime_seconds(kernel_ft)
total_s = user_s + kernel_s
except BaseException:
if job:
kernel32.TerminateJobObject(job, 1)
if pi.hProcess:
kernel32.TerminateProcess(pi.hProcess, 1)
kernel32.WaitForSingleObject(pi.hProcess, TERMINATE_WAIT_MS)
raise
finally:
if pi.hThread:
kernel32.CloseHandle(pi.hThread)
if pi.hProcess:
kernel32.CloseHandle(pi.hProcess)
if job:
kernel32.CloseHandle(job)
for f in stdio_files:
f.close()
result = LaunchResult(
pid=int(pi.dwProcessId),
exit_code=exit_code,
timed_out=timed_out,
wall_seconds=time.perf_counter() - start,
cpu_user_seconds=user_s,
cpu_kernel_seconds=kernel_s,
cpu_total_seconds=total_s,
)
return result, effective
def launch_portable(
argv: Sequence[str],
cpu_percent: int | None,
affinity_mask: int | None,
timeout: float | None,
cwd: str | None = None,
stdout_log: Path | None = None,
stderr_log: Path | None = None,
cpu_rate_units: int | None = None,
cpu_rate_mode: str = "host_percent",
core_percent: float | None = None,
) -> tuple[LaunchResult, dict[str, Any]]:
if cpu_percent is not None or cpu_rate_units is not None or core_percent is not None:
raise UnsupportedThrottle("CPU throttling is only supported on Windows via Job Objects")
if affinity_mask is not None and not hasattr(os, "sched_setaffinity"):
raise UnsupportedThrottle("affinity is unsupported on this platform")
def set_affinity() -> None:
if affinity_mask is not None and hasattr(os, "sched_setaffinity"):
cpus = {i for i in range(affinity_mask.bit_length()) if affinity_mask & (1 << i)}
os.sched_setaffinity(0, cpus) # type: ignore[attr-defined]
start = time.perf_counter()
stdout_f = None
stderr_f = None
stdout_target: Any = None
stderr_target: Any = None
if stdout_log:
stdout_log.parent.mkdir(parents=True, exist_ok=True)
stdout_f = open(stdout_log, "wb")
stdout_target = stdout_f
if stderr_log:
stderr_log.parent.mkdir(parents=True, exist_ok=True)
stderr_f = open(stderr_log, "ab" if stdout_log and stderr_log == stdout_log else "wb")
stderr_target = stderr_f
proc = subprocess.Popen(
list(argv),
cwd=cwd,
preexec_fn=set_affinity if affinity_mask is not None and os.name == "posix" else None,
start_new_session=os.name == "posix",
stdout=stdout_target,
stderr=stderr_target,
)
timed_out = False
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out = True
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
proc.kill()
proc.wait()
except BaseException:
if proc.poll() is None:
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
proc.kill()
proc.wait()
raise
finally:
if stdout_f:
stdout_f.close()
if stderr_f:
stderr_f.close()
result = LaunchResult(
pid=proc.pid,
exit_code=proc.returncode,
timed_out=timed_out,
wall_seconds=time.perf_counter() - start,
cpu_user_seconds=None,
cpu_kernel_seconds=None,
cpu_total_seconds=None,
)
return result, {
"platform": "portable",
"throttle_supported": False,
"cpu_rate_mode": cpu_rate_mode,
"core_percent": core_percent,
"job_cpu_rate_units": None,
"job_cpu_rate_denominator": None,
"affinity_mask": affinity_mask,
"affinity_applied": affinity_mask is not None and hasattr(os, "sched_setaffinity"),
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
"stdio_mode": "log-files" if stdout_log or stderr_log else "inherit",
}
def run_host(
argv: Sequence[str],
cpu_percent: int | None,
affinity_mask: int | None,
timeout: float | None,
cwd: str | None = None,
stdout_log: Path | None = None,
stderr_log: Path | None = None,
core_percent: float | None = None,
) -> dict[str, Any]:
cpu_percent = validate_cpu_percent(cpu_percent)
core_percent = validate_core_percent(core_percent)
if cpu_percent is not None and core_percent is not None:
raise ValueError("--cpu-percent and --core-percent are mutually exclusive")
timeout = validate_timeout(timeout)
cpu_rate_mode = "host_percent"
cpu_rate_units = host_cpu_rate_units(cpu_percent)
if core_percent is not None:
cpu_rate_mode = "one_core_percent"
cpu_rate_units = one_core_cpu_rate_units(core_percent, os.cpu_count())
launcher = launch_windows if os.name == "nt" else launch_portable
result, effective = launcher(
argv,
cpu_percent,
affinity_mask,
timeout,
cwd,
stdout_log,
stderr_log,
cpu_rate_units,
cpu_rate_mode,
core_percent,
)
return {
"schema_version": 1,
"host": topology_report(),
"request": {
"cpu_percent": cpu_percent,
"core_percent": core_percent,
"affinity": affinity_mask,
"timeout_seconds": timeout,
"argv": list(argv),
"cwd": cwd,
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
},
"effective": effective,
"process": {
"pid": result.pid,
"exit_code": result.exit_code,
"timed_out": result.timed_out,
"wall_seconds": result.wall_seconds,
"cpu_user_seconds": result.cpu_user_seconds,
"cpu_kernel_seconds": result.cpu_kernel_seconds,
"cpu_total_seconds": result.cpu_total_seconds,
},
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run a command under a process-scoped host CPU budget."
)
parser.add_argument("--cpu-percent", type=int, default=None,
help="Windows Job Object hard CPU cap, 1..100 percent.")
parser.add_argument("--core-percent", type=float, default=None,
help=(
"Windows cap as percent of one logical CPU; "
"converts to raw Job Object units using host "
"logical CPU count, independent of affinity."
))
parser.add_argument("--affinity", default=None,
help="Affinity mask, e.g. 0x3, or CPU list, e.g. 0,1.")
parser.add_argument("--timeout", type=float, default=None,
help="Seconds before terminating only the launched job/process.")
parser.add_argument("--report", type=Path, default=None,
help="Write JSON report to this path. Defaults to stdout.")
parser.add_argument("--stdout-log", type=Path, default=None,
help="Write child stdout to this path.")
parser.add_argument("--stderr-log", type=Path, default=None,
help="Write child stderr to this path.")
parser.add_argument("command", nargs=argparse.REMAINDER,
help="Command to run after --.")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
ns = parser.parse_args(argv)
command = list(ns.command)
if command and command[0] == "--":
command = command[1:]
if not command:
parser.error("missing command after --")
stdout_log = ns.stdout_log
stderr_log = ns.stderr_log
if ns.report:
if stdout_log is None:
stdout_log = ns.report.with_name(ns.report.name + ".stdout.log")
if stderr_log is None:
stderr_log = ns.report.with_name(ns.report.name + ".stderr.log")
try:
report = run_host(
command,
ns.cpu_percent,
parse_affinity(ns.affinity),
ns.timeout,
os.getcwd(),
stdout_log,
stderr_log,
ns.core_percent,
)
except (PerfHostError, OSError, ValueError) as exc:
error = {
"schema_version": 1,
"host": topology_report(),
"request": {
"cpu_percent": ns.cpu_percent,
"core_percent": ns.core_percent,
"affinity": ns.affinity,
"timeout_seconds": ns.timeout,
"argv": command,
"cwd": os.getcwd(),
"stdout_log": _path_text(stdout_log),
"stderr_log": _path_text(stderr_log),
},
"error": str(exc),
}
body = json.dumps(error, indent=2, sort_keys=True)
if ns.report:
ns.report.parent.mkdir(parents=True, exist_ok=True)
ns.report.write_text(body + "\n", encoding="utf-8")
else:
print(body)
return 2
body = json.dumps(report, indent=2, sort_keys=True)
if ns.report:
ns.report.parent.mkdir(parents=True, exist_ok=True)
ns.report.write_text(body + "\n", encoding="utf-8")
else:
print(body)
return int(report["process"]["exit_code"] or 0)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,10 @@
{
"name": "ape-crabby-water-idle",
"title": "Ape Escape (USA)",
"scene": "Crabby Beach, sandcastle area facing the water",
"state_sha256": "2dfc142e65eb32e179b67d352f510d15f6d97efd22b3b43a9eef24301f3ad5a9",
"notes": "Stationary initial workload, 7200 native input-route ticks. Requires the separately supplied compatible v5 slot01 state. This does not reproduce an analog approach to the water and is not a portable savestate fixture. Original state remains local; see docs/performance/EXPERIMENTS.md for identity and limitations.",
"steps": [
{"frames": 7200, "buttons": 65535}
]
}
@@ -22,8 +22,9 @@ static uint32_t seen_address, cycles;
static int32_t bound(int32_t x) { return x * 2; }
static int margin120(void) { return 120; }
static void advance(uint32_t n) { cycles += n; }
static void entry(CPUState *cpu, uint32_t address) {
static int entry(CPUState *cpu, uint32_t address) {
assert(cycles == 17); seen_cpu = cpu; seen_address = address;
return 1;
}
int main(void) {
CPUState cpu = {0};
@@ -36,12 +37,12 @@ int main(void) {
assert(psx_ws_screen_x_bound(-256) == -512);
assert(psx_ws_screen_x_bound(256) == 512);
psx_advance_cycles(17);
psx_mod_function_entry(&cpu, 0x80045770);
assert(psx_mod_function_entry(&cpu, 0x80045770) == 1);
assert(seen_cpu == &cpu && seen_address == 0x80045770);
callbacks.ws_screen_x_bound = 0; callbacks.mod_function_entry = 0;
overlay_init(&callbacks);
assert(psx_ws_screen_x_bound(-256) == -256);
psx_mod_function_entry(&cpu, 0);
assert(psx_mod_function_entry(&cpu, 0) == 0);
assert(seen_address == 0x80045770);
/* bgez_sites / clip_edge_x_load_sites in overlay code: identity with no
* margin callback (4:3 host), widened by the live margin with one. */
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env python3
"""Focused tests for perf_campaign.py evidence semantics."""
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("perf_campaign", ROOT / "perf_campaign.py")
PERF = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = PERF
SPEC.loader.exec_module(PERF)
def base_reply(name, params):
if name == "ping":
return {"id": 1, "ok": True, "frame": 100, "dispatch_miss_total": 0,
"dispatch_miss_unique": 0}
if name == "freeze_check":
return {"id": 1, "ok": True, "psx_cycle_count": 1_000_000,
"frame_count": 100, "dirty_ram_insns": 10,
"dirty_ram_blocks": 2, "dirty_ram_aborts": 0}
if name == "overlay_loader_status":
return {"id": 1, "ok": True, "loads": 1, "invalidations": 0,
"revalidations": 0, "dispatch_native": 100,
"dispatch_interp_fallback": 5, "stale_blocked": 0}
if name == "autocompile_status":
return {"id": 1, "ok": True, "compile": {"runs": 1, "fails": 0,
"shard_fail_total": 0, "configured": 1, "degraded": 0}}
if name == "dirty_ram_stats":
return {"id": 1, "ok": True, "blocks_run": 2, "insns_run": 10,
"native_handoffs": 1, "per_pc": []}
if name == "kernel_bless":
return {"id": 1, "ok": True, "entries": 0, "clean": 0,
"mismatch": 0, "native_hits": 0}
if name == "frame_perf":
return {"id": 1, "ok": True, "samples": 30,
"all": {"total_ms_avg": 16.0, "total_ms_max": 20.0}}
if name == "phase_profile":
return {"id": 1, "ok": True, "window_s": params["window"], "samples": 100,
"interp_share": 0.1, "native_share": 0.2, "static_share": 0.3,
"gpu_share": 0.1, "other_share": 0.3, "exc_share": 0.0}
if name == "phase_hot":
return {"id": 1, "ok": True, "set": params.get("set", "native"),
"phase_samples_total": 10, "hash_drops": 0,
"top": [{"addr": "0x80010000", "samples": 10}]}
if name in ("bios_info", "game_options", "pace_state", "turbo_state", "gpu_state"):
return {"id": 1, "ok": True, "name": name}
if name in ("clear_input", "input_route_clear", "input_route_stop"):
return {"id": 1, "ok": True}
if name == "input_route_append":
return {"id": 1, "ok": True, "steps": params["_step"]}
if name == "input_route_start":
return {"id": 1, "ok": True, "steps": 2, "start_frame": 100}
if name == "input_route_status":
return {"id": 1, "ok": True, "active": False, "steps": 2,
"index": 2, "remaining": 0}
if name == "starv_ring":
return {"id": 1, "ok": True, "total": 2, "returned": 2, "entries": [
{"seq": 0, "kind": 15, "us": 1000, "cyc": 100, "func": "0x1",
"store_pc": "0x0", "in_exc": 0},
{"seq": 1, "kind": 15, "us": 2000, "cyc": 200, "func": "0x2",
"store_pc": "0x0", "in_exc": 0},
]}
raise AssertionError(name)
class FakeClient:
def __init__(self, overrides=None):
self.overrides = overrides or {}
self.calls = []
self.append_count = 0
self.sample_index = 0
def cmd(self, name, **params):
self.calls.append((name, params))
if name == "input_route_append":
self.append_count += 1
params = dict(params)
params["_step"] = self.append_count
if name in self.overrides:
value = self.overrides[name]
if callable(value):
return value(name, params)
return value
reply = base_reply(name, params)
if name == "freeze_check":
self.sample_index += 1
reply = dict(reply)
reply["psx_cycle_count"] += self.sample_index * 1_000_000
reply["frame_count"] += self.sample_index * 60
reply["dirty_ram_insns"] += self.sample_index * 10
reply["dirty_ram_blocks"] += self.sample_index
elif name in ("ping", "overlay_loader_status", "dirty_ram_stats"):
reply = json.loads(json.dumps(reply))
bump = self.sample_index
if name == "ping":
reply["frame"] += bump * 60
if name == "overlay_loader_status":
reply["dispatch_native"] += bump * 100
if name == "dirty_ram_stats":
reply["insns_run"] += bump * 10
reply["blocks_run"] += bump
elif name == "phase_hot":
reply = json.loads(json.dumps(reply))
reply["phase_samples_total"] += self.sample_index * 10
reply["top"][0]["samples"] += self.sample_index * 10
return reply
class FakeSocket:
def __init__(self, reply):
self.reply = reply
self.closed = False
def sendall(self, _body):
pass
def recv(self, _size):
if self.reply is None:
return b""
data = self.reply
self.reply = None
return data
def close(self):
self.closed = True
class PerfCampaignTests(unittest.TestCase):
def test_subparser_does_not_reset_root_common_options(self):
args = PERF.build_parser().parse_args([
"--port", "4680", "sample", "--route", "r.json", "--out", "o.json"])
self.assertEqual(args.port, 4680)
def test_subparser_can_override_root_common_options(self):
args = PERF.build_parser().parse_args([
"--port", "4370", "sample", "--port", "4680",
"--route", "r.json", "--out", "o.json"])
self.assertEqual(args.port, 4680)
def test_client_rejects_response_id_mismatch(self):
with mock.patch.object(PERF.DEBUG_CLIENT, "connect",
return_value=FakeSocket(b'{"id":99,"ok":true}')):
reply = PERF.Client("127.0.0.1", 1).cmd("ping")
self.assertFalse(reply["ok"])
self.assertIn("response id mismatch", reply["error"])
def test_load_route_validates_and_hashes_json(self):
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "route.json"
path.write_text(json.dumps({"name": "crabby", "steps": [
{"frames": 2, "buttons": "0xFFFF"},
{"frames": 3, "buttons": 0xBFFF},
]}), encoding="utf-8")
route = PERF.load_route(path)
self.assertEqual(route["name"], "crabby")
self.assertEqual(route["total_frames"], 5)
self.assertEqual(route["steps"][1]["buttons"], 0xBFFF)
self.assertEqual(len(route["sha256"]), 64)
def test_analog_route_fails_closed_because_native_route_is_digital_only(self):
step = PERF.RouteStep(frames=1, buttons=0xFFFF, lx=0)
with self.assertRaisesRegex(PERF.CampaignError, "digital-only"):
step.append_params()
def test_snapshot_marks_frame_perf_unavailable_when_optional_command_fails(self):
client = FakeClient({"frame_perf": {"id": 1, "ok": False, "error": "no samples"}})
snap = PERF.snapshot(client, phase_window=5, hot_top=10)
self.assertNotIn("frame_perf", snap["commands"])
self.assertIn("optional frame_perf unavailable", snap["warnings"][0])
def test_snapshot_keeps_optional_provenance_warnings(self):
client = FakeClient({"bios_info": {"id": 1, "ok": False, "error": "missing"}})
snap = PERF.snapshot(client, phase_window=5, hot_top=10)
self.assertIn("optional bios_info unavailable", snap["warnings"][0])
def test_counter_reset_fails_closed(self):
before = PERF.snapshot(FakeClient(), phase_window=5, hot_top=10)
after = PERF.snapshot(FakeClient(), phase_window=5, hot_top=10)
after["commands"]["dirty_ram_stats"]["insns_run"] = 0
with self.assertRaisesRegex(PERF.CampaignError, "counter reset"):
PERF.summarize_window(before, after)
def test_missing_counter_fails_closed(self):
before = PERF.snapshot(FakeClient(), phase_window=5, hot_top=10)
after = PERF.snapshot(FakeClient(), phase_window=5, hot_top=10)
del after["commands"]["freeze_check"]["psx_cycle_count"]
with self.assertRaisesRegex(PERF.CampaignError, "missing field"):
PERF.summarize_window(before, after)
def test_run_sample_records_raw_evidence_and_summary(self):
args = argparse_like(timeout=10.0, poll=0.0, hot_top=10, starv_count=4,
label="A1", primary_metric="summary.route_wall_s",
lower_is_better=True, host="127.0.0.1", port=4370)
route = {"name": "unit", "steps": [
{"frames": 1, "buttons": 0xFFFF, "lx": None, "ly": None, "rx": None, "ry": None},
{"frames": 1, "buttons": 0xBFFF, "lx": None, "ly": None, "rx": None, "ry": None},
], "total_frames": 2}
with mock.patch.object(PERF.time, "monotonic", side_effect=monotonic_clock()):
sample = PERF.run_sample(FakeClient(), route, args)
self.assertTrue(sample["success"], sample.get("error"))
self.assertIn("before", sample)
self.assertIn("after", sample)
self.assertGreater(sample["summary"]["cycle_delta"], 0)
self.assertGreaterEqual(sample["summary"]["route_wall_s"], 0.0)
self.assertIn("observed_counter_window_wall_s", sample["summary"])
self.assertIn("debug-tools TCP", sample["limitations"][0])
def test_run_sample_keeps_partial_report_on_failure(self):
args = argparse_like(timeout=10.0, poll=0.0, hot_top=10, starv_count=4,
label="A1", primary_metric="summary.route_wall_s",
lower_is_better=True, host="127.0.0.1", port=4370)
route = {"name": "unit", "steps": [
{"frames": 1, "buttons": 0xFFFF, "lx": 0, "ly": None, "rx": None, "ry": None},
], "total_frames": 1}
sample = PERF.run_sample(FakeClient(), route, args)
self.assertFalse(sample["success"])
self.assertIn("digital-only", sample["error"])
self.assertIn("partial_after", sample)
def test_run_sample_allows_missing_frame_perf(self):
args = argparse_like(timeout=10.0, poll=0.0, hot_top=10, starv_count=4,
label="A1", primary_metric="summary.route_wall_s",
lower_is_better=True, host="127.0.0.1", port=4370)
route = {"name": "unit", "steps": [
{"frames": 1, "buttons": 0xFFFF, "lx": None, "ly": None, "rx": None, "ry": None},
{"frames": 1, "buttons": 0xBFFF, "lx": None, "ly": None, "rx": None, "ry": None},
], "total_frames": 2}
with mock.patch.object(PERF.time, "monotonic", side_effect=monotonic_clock()):
sample = PERF.run_sample(
FakeClient({"frame_perf": {"id": 1, "ok": False, "error": "disabled"}}),
route, args)
self.assertTrue(sample["success"], sample.get("error"))
self.assertFalse(sample["summary"]["frame_perf_available"])
self.assertIn("optional frame_perf unavailable", "\n".join(sample["warnings"]))
def test_route_done_requires_all_steps_consumed(self):
client = FakeClient({"input_route_status": {"id": 1, "ok": True,
"active": False, "steps": 2, "index": 1,
"remaining": 0}})
with self.assertRaisesRegex(PERF.CampaignError, "before consuming"):
PERF.wait_route_done(client, timeout_s=1.0, poll_s=0.01, expected_steps=2)
def test_restore_wait_requires_completion_generation_and_success(self):
class RestoreClient:
def __init__(self):
self.statuses = [
{"id": 1, "ok": True, "generation": 1, "pending": 0,
"last_ok": 1, "last_op": "save", "last_slot": 0},
{"id": 3, "ok": True, "generation": 2, "pending": 0,
"last_ok": 1, "last_op": "load", "last_slot": 2},
]
def cmd(self, name, **params):
if name == "savestate_status":
return self.statuses.pop(0)
if name == "savestate":
self.staged = params
return {"id": 2, "ok": True, "op": "load", "slot": 2}
raise AssertionError(name)
client = RestoreClient()
witness = PERF.wait_restore_done(client, slot=2, timeout_s=1.0, poll_s=0.01)
self.assertEqual(client.staged, {"op": "load", "slot": 2})
self.assertEqual(witness["after"]["generation"], 2)
def test_state_file_hash_must_match_route_manifest(self):
args = argparse_like(timeout=10.0, poll=0.0, hot_top=10, starv_count=4,
label="A1", primary_metric="summary.route_wall_s",
lower_is_better=True, host="127.0.0.1", port=4370,
restore_slot=1, restore_timeout=1.0)
with tempfile.TemporaryDirectory() as td:
state = Path(td) / "state.pst"
state.write_bytes(b"state")
args.state_file = state
route = {"name": "unit", "state_sha256": "wrong", "steps": [
{"frames": 1, "buttons": 0xFFFF, "lx": None, "ly": None, "rx": None, "ry": None},
], "total_frames": 1}
client = FakeClient()
sample = PERF.run_sample(client, route, args)
self.assertFalse(sample["success"])
self.assertIn("state_sha256", sample["error"])
self.assertEqual(client.calls, [])
def test_ab_requires_three_pairs(self):
sample = successful_sample(1.0)
with self.assertRaisesRegex(PERF.CampaignError, "at least three"):
PERF.summarize_ab([sample, sample], [sample, sample], "summary.route_wall_s", True)
def test_ab_reports_min_median_and_interleaving(self):
a = [successful_sample(v) for v in (10.0, 12.0, 11.0)]
b = [successful_sample(v) for v in (9.0, 9.5, 10.0)]
stamp_interleaved(a, b)
report = PERF.summarize_ab(a, b, "summary.route_wall_s", True)
self.assertEqual(report["accepted_pairs"], 3)
self.assertEqual(report["declared_pair_order"], ["A1", "B1", "A2", "B2", "A3", "B3"])
self.assertEqual(report["interleaving_proof"]["status"], "proven")
self.assertAlmostEqual(report["best_of_n"]["change_percent"], 10.0)
self.assertAlmostEqual(report["medians"]["change_percent"], (11.0 - 9.5) / 11.0 * 100.0)
def test_metric_path_must_exist(self):
with self.assertRaisesRegex(PERF.CampaignError, "missing metric"):
PERF.metric_from_sample(successful_sample(1.0), "summary.nope")
def test_ab_rejects_mismatched_route_identity(self):
a = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
b = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
b[1]["provenance"]["route"]["sha256"] = "different"
with self.assertRaisesRegex(PERF.CampaignError, "route identity differs"):
PERF.summarize_ab(a, b, "summary.route_wall_s", True)
def test_ab_rejects_non_finite_metric(self):
sample = successful_sample(float("nan"))
with self.assertRaisesRegex(PERF.CampaignError, "not finite"):
PERF.metric_from_sample(sample, "summary.route_wall_s")
def test_ab_rejects_unproven_interleaving(self):
a = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
b = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
with self.assertRaisesRegex(PERF.CampaignError, "interleaving not proven"):
PERF.summarize_ab(a, b, "summary.route_wall_s", True)
def test_ab_rejects_missing_target_provenance(self):
a = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
b = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
stamp_interleaved(a, b)
del b[0]["provenance"]["target"]
with self.assertRaisesRegex(PERF.CampaignError, "target"):
PERF.summarize_ab(a, b, "summary.route_wall_s", True)
def test_ab_allows_different_target_between_sides_but_not_within_side(self):
a = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
b = [successful_sample(v) for v in (1.0, 2.0, 3.0)]
for sample in b:
sample["provenance"]["target"]["artifact_sha256"] = sha("b")
sample["provenance"]["target"]["variant_selector"] = "candidate"
stamp_interleaved(a, b)
report = PERF.summarize_ab(a, b, "summary.route_wall_s", True)
self.assertEqual(
report["identity"]["sides"]["B"]["target_binary"]["artifact_sha256"],
sha("b"))
b[2]["provenance"]["target"]["artifact_sha256"] = sha("c")
with self.assertRaisesRegex(PERF.CampaignError, "within side B"):
PERF.summarize_ab(a, b, "summary.route_wall_s", True)
def test_discards_exclude_pairs_before_minimum_rule(self):
a = [successful_sample(v) for v in (10.0, 100.0, 12.0, 11.0)]
b = [successful_sample(v) for v in (9.0, 100.0, 9.5, 10.0)]
stamp_interleaved(a, b)
kept_a, kept_b, discarded = PERF.apply_discards(
a, b, [{"pair": 2, "reason": "compiler"}])
report = PERF.summarize_ab(kept_a, kept_b, "summary.route_wall_s", True, discarded)
self.assertEqual(report["accepted_pairs"], 3)
self.assertEqual(report["raw_a"], [10.0, 12.0, 11.0])
self.assertEqual(report["discarded_pairs"][0]["a"], None)
def test_load_target_provenance_requires_explicit_keys(self):
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "target.json"
path.write_text(json.dumps(target_provenance()), encoding="utf-8")
loaded = PERF.load_target_provenance(path)
self.assertEqual(loaded["artifact_sha256"], sha("a"))
self.assertEqual(len(loaded["source_sha256"]), 64)
bad = Path(td) / "bad.json"
bad.write_text("{}", encoding="utf-8")
with self.assertRaisesRegex(PERF.CampaignError, "missing required keys"):
PERF.load_target_provenance(bad)
def test_target_provenance_rejects_empty_or_malformed_values(self):
target = target_provenance()
target["artifact_sha256"] = ""
with self.assertRaisesRegex(PERF.CampaignError, "artifact_sha256"):
PERF.validate_target_shape(target, "target")
target = target_provenance()
target["host_id"] = None
with self.assertRaisesRegex(PERF.CampaignError, "host_id"):
PERF.validate_target_shape(target, "target")
target = target_provenance()
target["instrumentation"] = {}
with self.assertRaisesRegex(PERF.CampaignError, "instrumentation"):
PERF.validate_target_shape(target, "target")
target = target_provenance()
target["cpu_budget"] = {"cap": 1, "denominator": 1}
with self.assertRaisesRegex(PERF.CampaignError, "affinity"):
PERF.validate_target_shape(target, "target")
def argparse_like(**kwargs):
class Obj:
pass
obj = Obj()
for key, value in kwargs.items():
setattr(obj, key, value)
return obj
def successful_sample(value):
return {
"success": True,
"label": "x",
"primary_metric": {"name": "summary.route_wall_s", "lower_is_better": True},
"provenance": {
"tcp": {"host": "127.0.0.1", "port": 4370},
"repo": {"commit": "tools", "branch": "b", "dirty": False},
"target": target_provenance(),
"route": {"name": "unit", "sha256": "abc", "total_frames": 2,
"state_sha256": "state", "restore_witness": {"generation": 1}},
},
"summary": {
"route_wall_s": value,
"route_start_wall": 0.0,
"route_end_wall": 0.5,
},
}
def target_provenance():
return {
"artifact_sha256": sha("a"),
"settings_sha256": sha("1"),
"bios_sha256": sha("b"),
"overlay_inventory_sha256": sha("0"),
"host_id": "host",
"cpu_budget": {"cap": 1, "denominator": 1, "affinity": "all"},
"instrumentation": {"debug_tcp": True, "gl_perf": True},
"variant_selector": "baseline",
}
def monotonic_clock(start=1000.0, step=0.01):
current = start
while True:
current += step
yield current
def sha(seed):
return (seed * 64)[:64]
def stamp_interleaved(a, b):
t = 0.0
for pair_a, pair_b in zip(a, b):
pair_a["summary"]["route_start_wall"] = t
pair_a["summary"]["route_end_wall"] = t + 0.4
t += 1.0
pair_b["summary"]["route_start_wall"] = t
pair_b["summary"]["route_end_wall"] = t + 0.4
t += 1.0
if __name__ == "__main__":
unittest.main()
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Tests for perf_host.py."""
from __future__ import annotations
import importlib.util
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
SPEC = importlib.util.spec_from_file_location("perf_host", ROOT / "tools" / "perf_host.py")
PERF = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules["perf_host"] = PERF
SPEC.loader.exec_module(PERF)
class PerfHostTests(unittest.TestCase):
def test_parse_affinity_accepts_mask_and_list(self):
self.assertEqual(PERF.parse_affinity("0x3"), 0x3)
self.assertEqual(PERF.parse_affinity("1,3"), 0b1010)
self.assertIsNone(PERF.parse_affinity(None))
def test_parse_affinity_rejects_zero_and_negative_index(self):
with self.assertRaises(ValueError):
PERF.parse_affinity("0")
with self.assertRaises(ValueError):
PERF.parse_affinity("0,-1")
def test_validate_cpu_percent_range(self):
self.assertEqual(PERF.validate_cpu_percent(25), 25)
self.assertIsNone(PERF.validate_cpu_percent(None))
with self.assertRaises(ValueError):
PERF.validate_cpu_percent(0)
with self.assertRaises(ValueError):
PERF.validate_cpu_percent(101)
def test_validate_core_percent_range(self):
self.assertEqual(PERF.validate_core_percent(12.5), 12.5)
self.assertIsNone(PERF.validate_core_percent(None))
with self.assertRaises(ValueError):
PERF.validate_core_percent(0.0)
with self.assertRaises(ValueError):
PERF.validate_core_percent(float("inf"))
def test_affinity_scaled_cpu_rate_units(self):
self.assertEqual(PERF.affinity_scaled_cpu_rate_units(50, 0x1, 16), 313)
self.assertEqual(PERF.affinity_scaled_cpu_rate_units(25, 0x3, 8), 625)
self.assertEqual(PERF.affinity_scaled_cpu_rate_units(50, None, 16), 5000)
self.assertIsNone(PERF.affinity_scaled_cpu_rate_units(None, 0x1, 16))
self.assertEqual(PERF.one_core_cpu_rate_units(50, 16), 313)
self.assertEqual(PERF.one_core_cpu_rate_units(25, 16), 157)
def test_core_percent_computes_raw_rate_units(self):
fake = PERF.LaunchResult(
pid=123,
exit_code=0,
timed_out=False,
wall_seconds=0.125,
cpu_user_seconds=None,
cpu_kernel_seconds=None,
cpu_total_seconds=None,
)
with mock.patch.object(PERF, "launch_windows",
return_value=(fake, {"platform": "windows"})) as launcher, \
mock.patch.object(PERF.os, "name", "nt"), \
mock.patch.object(PERF.os, "cpu_count", return_value=16):
PERF.run_host(["tool"], None, 0x1, 1.0, core_percent=50)
self.assertEqual(launcher.call_args.args[7], 313)
self.assertEqual(launcher.call_args.args[8], "one_core_percent")
def test_cpu_percent_and_core_percent_are_mutually_exclusive(self):
with self.assertRaises(ValueError):
PERF.run_host(["tool"], 50, 0x1, 1.0, core_percent=50)
def test_portable_throttle_fails_honestly(self):
with self.assertRaises(PERF.UnsupportedThrottle):
PERF.launch_portable([sys.executable, "-c", "pass"], 25, None, 1.0)
def test_run_host_records_report_shape_with_mock_launcher(self):
fake = PERF.LaunchResult(
pid=123,
exit_code=7,
timed_out=False,
wall_seconds=0.125,
cpu_user_seconds=0.01,
cpu_kernel_seconds=0.02,
cpu_total_seconds=0.03,
)
with mock.patch.object(PERF, "launch_portable",
return_value=(fake, {"platform": "portable"})), \
mock.patch.object(PERF.os, "name", "posix"):
report = PERF.run_host(["tool", "arg"], None, 0x3, 2.0, "cwd")
self.assertEqual(report["request"]["argv"], ["tool", "arg"])
self.assertEqual(report["request"]["affinity"], 0x3)
self.assertEqual(report["effective"]["platform"], "portable")
self.assertEqual(report["process"]["exit_code"], 7)
def test_main_writes_error_report_for_unsupported_throttle(self):
with tempfile.TemporaryDirectory() as td:
report_path = Path(td) / "report.json"
with mock.patch.object(
PERF,
"run_host",
side_effect=PERF.UnsupportedThrottle(
"CPU throttling is only supported on Windows via Job Objects"
),
):
rc = PERF.main([
"--cpu-percent", "50",
"--report", str(report_path),
"--",
sys.executable, "-c", "pass",
])
body = json.loads(report_path.read_text(encoding="utf-8"))
self.assertEqual(rc, 2)
self.assertIn("CPU throttling is only supported", body["error"])
def test_windows_failure_terminates_scoped_job(self):
if os.name != "nt":
self.skipTest("Windows API layer is only defined on Windows")
calls: list[str] = []
class FakeKernel:
def CreateProcessW(self, app, cmd, psa, tsa, inherit, flags, env, cwd, si, pi):
pi._obj.hProcess = 100
pi._obj.hThread = 200
pi._obj.dwProcessId = 300
return True
def CreateJobObjectW(self, attrs, name):
return 400
def SetInformationJobObject(self, *args):
return True
def SetProcessAffinityMask(self, *args):
return True
def GetProcessAffinityMask(self, process, process_mask, system_mask):
process_mask._obj.value = 0x3
system_mask._obj.value = 0xFFFF
return True
def AssignProcessToJobObject(self, *args):
calls.append("assign")
return False
def ResumeThread(self, *args):
calls.append("resume")
return 0
def TerminateJobObject(self, *args):
calls.append("terminate_job")
return True
def TerminateProcess(self, *args):
calls.append("terminate_process")
return True
def WaitForSingleObject(self, *args):
calls.append("wait")
return PERF.WAIT_OBJECT_0
def CloseHandle(self, handle):
calls.append(f"close:{handle}")
return True
with mock.patch.object(PERF, "kernel32", FakeKernel()), \
mock.patch.object(PERF.ctypes, "get_last_error", return_value=5):
with self.assertRaisesRegex(PERF.PerfHostError, "AssignProcessToJobObject"):
PERF.launch_windows([sys.executable, "-c", "pass"], 50, None, 1.0)
self.assertIn("terminate_job", calls)
self.assertIn("terminate_process", calls)
self.assertNotIn("resume", calls)
calls.clear()
kernel = FakeKernel()
with mock.patch.object(PERF, "kernel32", kernel), \
mock.patch.object(kernel, "AssignProcessToJobObject",
side_effect=KeyboardInterrupt):
with self.assertRaises(KeyboardInterrupt):
PERF.launch_windows([sys.executable, "-c", "pass"], 50, None, 1.0)
self.assertIn("terminate_process", calls)
self.assertNotIn("resume", calls)
def test_windows_get_exit_code_failure_terminates_scoped_job(self):
if os.name != "nt":
self.skipTest("Windows API layer is only defined on Windows")
calls: list[str] = []
class FakeKernel:
def CreateProcessW(self, app, cmd, psa, tsa, inherit, flags, env, cwd, si, pi):
pi._obj.hProcess = 100
pi._obj.hThread = 200
pi._obj.dwProcessId = 300
return True
def CreateJobObjectW(self, attrs, name):
return 400
def SetInformationJobObject(self, *args):
return True
def AssignProcessToJobObject(self, *args):
return True
def ResumeThread(self, *args):
calls.append("resume")
return 0
def WaitForSingleObject(self, *args):
return PERF.WAIT_OBJECT_0
def GetExitCodeProcess(self, *args):
return False
def TerminateJobObject(self, *args):
calls.append("terminate_job")
return True
def TerminateProcess(self, *args):
calls.append("terminate_process")
return True
def CloseHandle(self, handle):
calls.append(f"close:{handle}")
return True
with mock.patch.object(PERF, "kernel32", FakeKernel()), \
mock.patch.object(PERF.ctypes, "get_last_error", return_value=6):
with self.assertRaisesRegex(PERF.PerfHostError, "GetExitCodeProcess"):
PERF.launch_windows([sys.executable, "-c", "pass"], None, None, 1.0)
self.assertIn("resume", calls)
self.assertIn("terminate_job", calls)
self.assertIn("terminate_process", calls)
def test_windows_create_process_failure_closes_stdio_files(self):
if os.name != "nt":
self.skipTest("Windows API layer is only defined on Windows")
class FakeKernel:
def CreateProcessW(self, *args):
return False
with tempfile.TemporaryDirectory() as td, \
mock.patch.object(PERF, "kernel32", FakeKernel()), \
mock.patch.object(PERF.ctypes, "get_last_error", return_value=2):
out = Path(td) / "child.out"
err = Path(td) / "child.err"
with self.assertRaisesRegex(PERF.PerfHostError, "CreateProcessW"):
PERF.launch_windows([sys.executable, "-c", "pass"], None, None, 1.0,
stdout_log=out, stderr_log=err)
out.unlink()
err.unlink()
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_unrestricted(self):
report = PERF.run_host([sys.executable, "-c", "import sys; sys.exit(3)"],
None, None, 5.0)
self.assertEqual(report["process"]["exit_code"], 3)
self.assertFalse(report["process"]["timed_out"])
self.assertEqual(report["effective"]["platform"], "windows")
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_preserves_logs(self):
with tempfile.TemporaryDirectory() as td:
report_path = Path(td) / "report.json"
rc = PERF.main([
"--timeout", "5",
"--report", str(report_path),
"--",
sys.executable, "-c",
"import sys; print('out'); print('err', file=sys.stderr); sys.exit(3)",
])
body = json.loads(report_path.read_text(encoding="utf-8"))
stdout_log = Path(body["effective"]["stdout_log"])
stderr_log = Path(body["effective"]["stderr_log"])
self.assertEqual(rc, 3)
self.assertEqual(stdout_log.read_text(encoding="utf-8").strip(), "out")
self.assertEqual(stderr_log.read_text(encoding="utf-8").strip(), "err")
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_exit_code_259_is_preserved(self):
report = PERF.run_host([sys.executable, "-c", "import sys; sys.exit(259)"],
None, None, 5.0)
self.assertEqual(report["process"]["exit_code"], 259)
self.assertFalse(report["process"]["timed_out"])
@unittest.skipIf(os.name != "nt", "Windows Job Object test")
def test_windows_real_child_timeout_with_cap(self):
report = PERF.run_host([sys.executable, "-c", "while True: pass"],
25, None, 0.2)
self.assertTrue(report["process"]["timed_out"])
self.assertEqual(report["effective"]["job_cpu_rate_units"], 2500)
if __name__ == "__main__":
unittest.main()