From fe31d87153c752ba7151b53a3ff9e6d3ccfeb19d Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Fri, 10 Jul 2026 13:38:15 +0200 Subject: [PATCH 1/3] Make Dolphin FIFO captures a trustworthy deep GX parity oracle Carry initial TMEM through DFF-to-DOLT conversion and export deterministic per-draw GX state, texture, matrix, vertex, post-clip, TEV, and pixel observations. Snapshot state at the true draw boundary so delayed sink delivery cannot attribute successor state to the previous draw. Constraint: DFF remains the hardware-shaped authority; JSONL is derived analyzer output. Rejected: Compare screenshots alone | cannot localize the first pipeline divergence. Confidence: high Scope-risk: moderate Directive: Preserve the DFF-parser self-check before using GXRuntime as an oracle. Tested: 7 CTest targets; 90-frame AC DFF with 20,511 and 22,328-draw exact analyzer parity; Aurora pixel replay Not-tested: Interactive input-driven scenes beyond the no-input attract loop --- .../dolruntime/aurora_recomp/dff2dolt.hpp | 8 +- .../dolruntime/aurora_recomp/replay.hpp | 20 +- .../aurora_recomp/retail_gx_frontend.hpp | 14 + .../dolruntime/aurora_recomp/trace.hpp | 4 + graphics/frontend/src/dff2dolt.cpp | 14 +- graphics/frontend/src/replay.cpp | 114 ++- graphics/frontend/src/retail_gx_frontend.cpp | 72 +- graphics/frontend/src/trace_io.cpp | 17 + graphics/frontend/tests/dff2dolt_test.cpp | 49 +- .../frontend/tests/frontend_replay_test.cpp | 38 +- graphics/frontend/tests/trace_io_test.cpp | 13 +- graphics/frontend/tools/dff2dolt.cpp | 9 +- graphics/frontend/tools/dolgx_replay.cpp | 701 ++++++++++++++++-- graphics/frontend/tools/dolgx_replay_core.cpp | 31 + 14 files changed, 984 insertions(+), 120 deletions(-) diff --git a/graphics/frontend/include/dolruntime/aurora_recomp/dff2dolt.hpp b/graphics/frontend/include/dolruntime/aurora_recomp/dff2dolt.hpp index d3af5c2..645f1ba 100644 --- a/graphics/frontend/include/dolruntime/aurora_recomp/dff2dolt.hpp +++ b/graphics/frontend/include/dolruntime/aurora_recomp/dff2dolt.hpp @@ -15,12 +15,11 @@ // emitted before the first FRAME_BEGIN, using // the exact command sequence + register // exclusion lists of FifoPlayer::LoadRegisters +// header TMEM snapshot -> one TMEM_SNAPSHOT record before the preamble // No PRESENT_STATS records are written (Aurora ground truth does not exist // for a Dolphin recording); replay closes frames at FRAME_BEGIN/EOF instead. // -// Not restored (v1 gaps, both logged in stats): TMEM snapshot (Dolphin -// memcpys it outside the FIFO; not expressible as raw commands — S9 scenes -// carry a near-empty snapshot) and FifoPlayer::ClearEfb's synthetic clear. +// Not restored: FifoPlayer::ClearEfb's synthetic clear. #include #include @@ -44,7 +43,8 @@ struct ConvertStats { std::uint32_t preamble_cp_regs = 0; std::uint32_t preamble_xf_words = 0; // XF memory words written (4096) std::uint32_t preamble_xf_regs = 0; - std::uint32_t tmem_nonzero_bytes = 0; // snapshot content we did NOT restore + std::uint32_t tmem_snapshot_bytes = 0; + std::uint32_t tmem_nonzero_bytes = 0; char game_id[9] = {}; }; diff --git a/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp b/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp index 42b34ee..25053a6 100644 --- a/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp +++ b/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp @@ -44,15 +44,23 @@ struct ReplayResult { std::vector frames; }; +using ReplayDrawObserver = void (*)(std::uint32_t frame_index, + std::uint32_t frame_draw, + const ConsumedDraw &draw, + unsigned long long cumulative_draw, + void *user); + // Replays every record of a freshly opened (or rewound) reader. The optional // observer taps every frontend decode event (histograms). -ReplayResult replay_trace( - trace::TraceReader& reader, - RetailGxFrontend::TraceEventObserver event_observer = nullptr, - void* event_observer_user = nullptr); +ReplayResult +replay_trace(trace::TraceReader &reader, + RetailGxFrontend::TraceEventObserver event_observer = nullptr, + void *event_observer_user = nullptr, + ReplayDrawObserver draw_observer = nullptr, + void *draw_observer_user = nullptr); // "frame N draws D zdraws Z verts V topo T store S elems E fnv X state H" -std::string format_digest_line(const FrameDigest& f); +std::string format_digest_line(const FrameDigest &f); struct StatsCompareResult { bool ok = false; @@ -69,6 +77,6 @@ struct StatsCompareResult { // 4*draws+4 (Aurora pads each unmerged draw to 4 bytes). Transient rule: the // run fails only when more than 2 consecutive frames mismatch. Topology/ // storage extents carry Aurora merge/cache caveats and are never gated. -StatsCompareResult compare_against_stats(const ReplayResult& result); +StatsCompareResult compare_against_stats(const ReplayResult &result); } // namespace dolruntime::aurora_recomp::replay diff --git a/graphics/frontend/include/dolruntime/aurora_recomp/retail_gx_frontend.hpp b/graphics/frontend/include/dolruntime/aurora_recomp/retail_gx_frontend.hpp index 8955928..553fc4a 100644 --- a/graphics/frontend/include/dolruntime/aurora_recomp/retail_gx_frontend.hpp +++ b/graphics/frontend/include/dolruntime/aurora_recomp/retail_gx_frontend.hpp @@ -14,6 +14,13 @@ extern "C" { namespace dolruntime::aurora_recomp { +// Replay-only physical aperture used to let the existing guest resolver carry +// Dolphin's initial TMEM snapshot into CI texture packets. It is outside GC +// MEM1 and remains an in-memory address identity; no .dolt guest memory record +// uses this range. +constexpr std::uint32_t kTmemSnapshotAddressBase = 0x1F000000u; +constexpr std::uint32_t kTmemSnapshotMaxBytes = 0x00100000u; + struct DrawTransformSnapshot { std::uint32_t transform_flags = 0; std::uint32_t current_pn_matrix = 0; @@ -50,6 +57,11 @@ class RetailGxFrontend { void reset(const DolGuestAddressResolver* resolver = nullptr); + // Installs the byte extent of a TMEM_SNAPSHOT record. The bytes remain owned + // by the replay context/resolver; this seeds snapshot-backed TLUT mappings as + // their BP texture state arrives. + bool restore_tmem_snapshot(std::uint32_t byte_size); + bool set_vertex_layout(std::uint8_t vtx_fmt, std::uint32_t vertex_size); bool set_indexed_attr(std::uint8_t vtx_fmt, std::uint8_t attr, std::uint32_t vertex_offset, std::uint8_t index_size, @@ -111,6 +123,7 @@ class RetailGxFrontend { bool record_fifo_bytes, std::uint32_t depth, std::size_t* consumed); bool handle_bp(std::uint32_t raw); + bool seed_snapshot_tlut(std::uint8_t slot); bool maybe_resolve_texture(std::uint8_t slot); bool handle_copy_trigger(std::uint32_t value); bool handle_draw(std::uint8_t command, @@ -135,6 +148,7 @@ class RetailGxFrontend { bool packet_drain_enabled_ = false; std::uint64_t zero_vertex_draws_ = 0; + std::uint32_t tmem_snapshot_size_ = 0; TraceEventObserver event_observer_ = nullptr; void* event_observer_user_ = nullptr; std::uint32_t emitted_trace_count_ = 0; diff --git a/graphics/frontend/include/dolruntime/aurora_recomp/trace.hpp b/graphics/frontend/include/dolruntime/aurora_recomp/trace.hpp index de7015a..8c4de75 100644 --- a/graphics/frontend/include/dolruntime/aurora_recomp/trace.hpp +++ b/graphics/frontend/include/dolruntime/aurora_recomp/trace.hpp @@ -34,6 +34,7 @@ enum class RecordKind : std::uint8_t { SetArray = 4, // u8 attr | u32 guest_addr | u16 stride — HLE bridge record MemUpdate = 5, // u32 guest_addr | u32 byte_size | bytes (resolved guest memory) PresentStats = 6, // u32 frame_index | 9 x u32 AuroraStats fields + TmemSnapshot = 7, // u32 byte_size | raw Dolphin TMEM bytes (initial state) }; struct TraceHeader { @@ -74,6 +75,7 @@ class TraceWriter { std::uint16_t stride); void mem_update(std::uint32_t guest_addr, const void* bytes, std::uint32_t byte_size); + void tmem_snapshot(const void* bytes, std::uint32_t byte_size); void present_stats(const PresentStats& stats); // Flushes and closes; returns false if any write failed at any point. @@ -135,6 +137,8 @@ bool decode_set_array(const RecordView& r, std::uint8_t& attr, std::uint32_t& guest_addr, std::uint16_t& stride); bool decode_mem_update(const RecordView& r, std::uint32_t& guest_addr, std::span& bytes); +bool decode_tmem_snapshot(const RecordView& r, + std::span& bytes); bool decode_present_stats(const RecordView& r, PresentStats& out); } // namespace dolruntime::aurora_recomp::trace diff --git a/graphics/frontend/src/dff2dolt.cpp b/graphics/frontend/src/dff2dolt.cpp index abd8843..76be79b 100644 --- a/graphics/frontend/src/dff2dolt.cpp +++ b/graphics/frontend/src/dff2dolt.cpp @@ -126,7 +126,8 @@ class DffFile { kXfRegsSize, xf_regs_, error, "XF register snapshot")) return false; - // TMEM snapshot (v4+): only counted, never restored (see header comment). + // TMEM snapshot (v4+): Dolphin restores this outside the FIFO before + // replaying the register snapshot and frame stream. tmem_nonzero_ = 0; if (header_.version >= 4u && header_.tex_mem_size != 0u) { if (!range_ok(header_.tex_mem_offset, header_.tex_mem_size)) @@ -188,6 +189,11 @@ class DffFile { const std::uint32_t* xf_mem() const { return xf_mem_; } const std::uint32_t* xf_regs() const { return xf_regs_; } std::uint32_t tmem_nonzero() const { return tmem_nonzero_; } + std::span tmem() const { + if (header_.version < 4u || header_.tex_mem_size == 0u) + return {}; + return {bytes_ + header_.tex_mem_offset, header_.tex_mem_size}; + } const std::uint8_t* file_bytes() const { return bytes_; } private: @@ -356,6 +362,8 @@ bool convert(const std::uint8_t* dff_bytes, std::size_t dff_size, *stats = ConvertStats{}; stats->dff_version = dff.header().version; stats->frames = dff.header().frame_count; + stats->tmem_snapshot_bytes = + static_cast(dff.tmem().size()); stats->tmem_nonzero_bytes = dff.tmem_nonzero(); std::memcpy(stats->game_id, dff.header().game_id, 8u); } @@ -371,6 +379,10 @@ bool convert(const std::uint8_t* dff_bytes, std::size_t dff_size, return false; } + const std::span tmem = dff.tmem(); + if (!tmem.empty()) + writer.tmem_snapshot(tmem.data(), static_cast(tmem.size())); + const std::vector preamble = build_state_preamble(dff, stats); write_gx_run(writer, preamble.data(), preamble.size(), stats); diff --git a/graphics/frontend/src/replay.cpp b/graphics/frontend/src/replay.cpp index eb2e848..76ac5b6 100644 --- a/graphics/frontend/src/replay.cpp +++ b/graphics/frontend/src/replay.cpp @@ -14,15 +14,15 @@ namespace { constexpr std::uint64_t kFnvBasis = 1469598103934665603ull; constexpr std::uint64_t kFnvPrime = 1099511628211ull; -void fnv_bytes(std::uint64_t& hash, const void* data, std::size_t size) { - const auto* bytes = static_cast(data); +void fnv_bytes(std::uint64_t &hash, const void *data, std::size_t size) { + const auto *bytes = static_cast(data); for (std::size_t i = 0; i < size; ++i) { hash ^= bytes[i]; hash *= kFnvPrime; } } -void fnv_u32(std::uint64_t& hash, std::uint32_t value) { +void fnv_u32(std::uint64_t &hash, std::uint32_t value) { for (unsigned i = 0; i < 4u; ++i) { hash ^= static_cast(value >> (i * 8u)); hash *= kFnvPrime; @@ -31,18 +31,38 @@ void fnv_u32(std::uint64_t& hash, std::uint32_t value) { struct ReplayContext { std::vector mem1; + std::vector tmem; RetailGxFrontend frontend; ConsumingAuroraRenderSink sink; std::uint64_t content_fnv = kFnvBasis; std::uint64_t state_fnv = kFnvBasis; + std::uint32_t current_frame = 0; + std::uint32_t frame_draw = 0; + ReplayDrawObserver draw_observer = nullptr; + void *draw_observer_user = nullptr; }; -bool mem1_resolver(void* user, u32 address, u32 size, +bool mem1_resolver(void *user, u32 address, u32 size, DolGuestAddressSpace space, DolGuestResourceKind resource, - DolGuestResolvedRange* out) { - auto* ctx = static_cast(user); + DolGuestResolvedRange *out) { + auto *ctx = static_cast(user); if (out == nullptr || size == 0u) return false; + if (resource == DOL_GUEST_RESOURCE_TLUT && + address >= kTmemSnapshotAddressBase) { + const u32 offset = address - kTmemSnapshotAddressBase; + if (offset >= ctx->tmem.size() || size > ctx->tmem.size() - offset) + return false; + *out = { + .data = ctx->tmem.data() + offset, + .address = address, + .size = size, + .available = static_cast(ctx->tmem.size() - offset), + .space = space, + .resource = resource, + }; + return true; + } const u32 physical = dol_gx_recomp_guest_to_physical(address); if (physical >= ctx->mem1.size() || size > ctx->mem1.size() - physical) return false; @@ -59,15 +79,15 @@ bool mem1_resolver(void* user, u32 address, u32 size, // Fold each span-complete draw into the frame's content/state digests. Runs // once per draw (on the next draw's arrival or at flush_assembly). -void digest_draw_observer(const ConsumedDraw& draw, unsigned long long, - void* user) { - auto* ctx = static_cast(user); +void digest_draw_observer(const ConsumedDraw &draw, unsigned long long, + void *user) { + auto *ctx = static_cast(user); fnv_bytes(ctx->content_fnv, draw.vertex_payload.data(), draw.vertex_payload.size()); std::vector elements; const AssembledDrawStats stats = assemble_consumed_draw(draw, &elements); fnv_u32(ctx->content_fnv, stats.ok ? 1u : 0u); - for (const AssembledElement& element : elements) { + for (const AssembledElement &element : elements) { fnv_u32(ctx->content_fnv, element.attr); fnv_u32(ctx->content_fnv, element.vertex); fnv_u32(ctx->content_fnv, element.index); @@ -84,35 +104,42 @@ void digest_draw_observer(const ConsumedDraw& draw, unsigned long long, fnv_bytes(ctx->state_fnv, draw.projection, sizeof draw.projection); fnv_bytes(ctx->state_fnv, draw.position_matrices, sizeof draw.position_matrices); + ++ctx->frame_draw; + if (ctx->draw_observer != nullptr) { + ctx->draw_observer(ctx->current_frame, ctx->frame_draw, draw, + ctx->sink.draw_packets(), ctx->draw_observer_user); + } } -std::string frontend_error_detail(const RetailGxFrontend& frontend) { +std::string frontend_error_detail(const RetailGxFrontend &frontend) { char buf[160]; - std::snprintf(buf, sizeof buf, - "%s (opcode=0x%02X offset=%zu detail=%u,%u,%u,%u)", - frontend.last_error() != nullptr ? frontend.last_error() - : "unknown", - static_cast(frontend.last_error_opcode()), - frontend.last_error_offset(), frontend.last_error_a(), - frontend.last_error_b(), frontend.last_error_c(), - frontend.last_error_d()); + std::snprintf( + buf, sizeof buf, "%s (opcode=0x%02X offset=%zu detail=%u,%u,%u,%u)", + frontend.last_error() != nullptr ? frontend.last_error() : "unknown", + static_cast(frontend.last_error_opcode()), + frontend.last_error_offset(), frontend.last_error_a(), + frontend.last_error_b(), frontend.last_error_c(), + frontend.last_error_d()); return buf; } } // namespace -ReplayResult replay_trace(trace::TraceReader& reader, +ReplayResult replay_trace(trace::TraceReader &reader, RetailGxFrontend::TraceEventObserver event_observer, - void* event_observer_user) { + void *event_observer_user, + ReplayDrawObserver draw_observer, + void *draw_observer_user) { ReplayResult result; auto ctx = std::make_unique(); const std::uint32_t mem1_size = reader.header().mem1_size != 0u ? reader.header().mem1_size : 0x01800000u; ctx->mem1.assign(mem1_size, 0u); + ctx->draw_observer = draw_observer; + ctx->draw_observer_user = draw_observer_user; DolGuestAddressResolver resolver; - dol_guest_address_resolver_init_callback(&resolver, mem1_resolver, - ctx.get()); + dol_guest_address_resolver_init_callback(&resolver, mem1_resolver, ctx.get()); ctx->frontend.reset(&resolver); ctx->frontend.set_event_observer(event_observer, event_observer_user); ctx->frontend.set_packet_drain_enabled(true); @@ -133,11 +160,11 @@ ReplayResult replay_trace(trace::TraceReader& reader, unsigned long long last_store = 0; unsigned long long last_elems = 0; - auto fail = [&](const std::string& message) { + auto fail = [&](const std::string &message) { result.parse_ok = false; char prefix[64]; - std::snprintf(prefix, sizeof prefix, "record %llu frame %u: ", - record_index, current_frame); + std::snprintf(prefix, sizeof prefix, "record %llu frame %u: ", record_index, + current_frame); result.error = prefix + message; }; @@ -146,7 +173,7 @@ ReplayResult replay_trace(trace::TraceReader& reader, // by the backend close every frame with PRESENT_STATS; converted traces // (dff2dolt) carry no Aurora stats, so their frames close at the next // FRAME_BEGIN or at end-of-trace instead. - auto close_frame = [&](const trace::PresentStats* stats) { + auto close_frame = [&](const trace::PresentStats *stats) { ctx->sink.flush_assembly(); FrameDigest digest; digest.frame_index = current_frame != 0u @@ -189,6 +216,8 @@ ReplayResult replay_trace(trace::TraceReader& reader, if (frame_open) close_frame(nullptr); current_frame = frame_index; + ctx->current_frame = frame_index; + ctx->frame_draw = 0; frame_open = true; break; } @@ -207,8 +236,7 @@ ReplayResult replay_trace(trace::TraceReader& reader, break; } for (unsigned i = 0; i < size; ++i) - bytes[i] = - static_cast(value >> ((size - 1u - i) * 8u)); + bytes[i] = static_cast(value >> ((size - 1u - i) * 8u)); if (!ctx->frontend.write_fifo({bytes, size}) || !ctx->frontend.flush(&ctx->sink)) { fail("frontend rejected FIFO: " + frontend_error_detail(ctx->frontend)); @@ -262,6 +290,21 @@ ReplayResult replay_trace(trace::TraceReader& reader, std::memcpy(ctx->mem1.data() + physical, bytes.data(), bytes.size()); break; } + case trace::RecordKind::TmemSnapshot: { + std::span bytes; + if (!trace::decode_tmem_snapshot(record, bytes) || + bytes.size() > kTmemSnapshotMaxBytes) { + fail("malformed TMEM_SNAPSHOT"); + break; + } + ctx->tmem.assign(bytes.begin(), bytes.end()); + if (!ctx->frontend.restore_tmem_snapshot( + static_cast(ctx->tmem.size())) || + !ctx->frontend.flush(&ctx->sink)) { + fail("frontend rejected TMEM snapshot"); + } + break; + } case trace::RecordKind::PresentStats: { trace::PresentStats stats{}; if (!trace::decode_present_stats(record, stats)) { @@ -285,7 +328,7 @@ ReplayResult replay_trace(trace::TraceReader& reader, return result; } -std::string format_digest_line(const FrameDigest& f) { +std::string format_digest_line(const FrameDigest &f) { char buf[208]; std::snprintf(buf, sizeof buf, "frame %u draws %llu zdraws %llu verts %llu topo %llu " @@ -297,7 +340,7 @@ std::string format_digest_line(const FrameDigest& f) { return buf; } -StatsCompareResult compare_against_stats(const ReplayResult& result) { +StatsCompareResult compare_against_stats(const ReplayResult &result) { StatsCompareResult r; unsigned long long consecutive = 0; // AuroraStats are published by the render worker one present late @@ -308,8 +351,8 @@ StatsCompareResult compare_against_stats(const ReplayResult& result) { // last frame has no partner and is ungated. Steady-state scenes are // shift-invariant; gameplay is not. for (std::size_t i = 0; i + 1 < result.frames.size(); ++i) { - const FrameDigest& f = result.frames[i]; - const FrameDigest& next = result.frames[i + 1]; + const FrameDigest &f = result.frames[i]; + const FrameDigest &next = result.frames[i + 1]; if (!next.has_stats) continue; if (next.frame_index != f.frame_index + 1u) @@ -318,9 +361,8 @@ StatsCompareResult compare_against_stats(const ReplayResult& result) { const bool draw_match = f.draws + f.zero_draws == next.stats.draw_call_count; const unsigned long long slack = 4ull * f.draws + 4ull; - const bool vert_match = - next.stats.last_vert_size >= f.vert_bytes && - (next.stats.last_vert_size - f.vert_bytes) <= slack; + const bool vert_match = next.stats.last_vert_size >= f.vert_bytes && + (next.stats.last_vert_size - f.vert_bytes) <= slack; if (draw_match && vert_match) { consecutive = 0; continue; diff --git a/graphics/frontend/src/retail_gx_frontend.cpp b/graphics/frontend/src/retail_gx_frontend.cpp index 1decf05..1406d9f 100644 --- a/graphics/frontend/src/retail_gx_frontend.cpp +++ b/graphics/frontend/src/retail_gx_frontend.cpp @@ -305,6 +305,7 @@ void RetailGxFrontend::reset(const DolGuestAddressResolver* resolver) { draw_payload_head_ = 0u; draw_transform_head_ = 0u; zero_vertex_draws_ = 0u; + tmem_snapshot_size_ = 0u; emitted_trace_count_ = 0u; next_packet_sequence_ = 0u; last_error_ = nullptr; @@ -316,6 +317,17 @@ void RetailGxFrontend::reset(const DolGuestAddressResolver* resolver) { last_error_d_ = 0u; } +bool RetailGxFrontend::restore_tmem_snapshot(std::uint32_t byte_size) { + if (byte_size > kTmemSnapshotMaxBytes) + return false; + tmem_snapshot_size_ = byte_size; + for (std::uint8_t slot = 0; slot < DOL_GX_RECOMP_TEXTURE_SLOTS; ++slot) { + if (!seed_snapshot_tlut(slot)) + return false; + } + return true; +} + bool RetailGxFrontend::set_vertex_layout(std::uint8_t vtx_fmt, std::uint32_t vertex_size) { return dol_gx_recomp_set_vertex_layout(&state_, vtx_fmt, vertex_size); @@ -702,7 +714,7 @@ bool RetailGxFrontend::handle_bp(std::uint32_t raw) { state_.texture_tlut_tmem_offset[slot] = static_cast(bp_get(value, 10u, 0u)); state_.texture_tlut_format[slot] = bp_get(value, 2u, 10u); - return true; + return seed_snapshot_tlut(slot) && maybe_resolve_texture(slot); } switch (reg) { @@ -739,6 +751,62 @@ bool RetailGxFrontend::handle_bp(std::uint32_t raw) { } } +bool RetailGxFrontend::seed_snapshot_tlut(std::uint8_t slot) { + if (tmem_snapshot_size_ == 0u || slot >= DOL_GX_RECOMP_TEXTURE_SLOTS || + !state_.texture_tlut_valid[slot] || !state_.textures[slot].valid) + return true; + + const std::uint32_t format = state_.textures[slot].format; + std::uint32_t entries = 0u; + switch (format) { + case 0x8u: // C4 + entries = 16u; + break; + case 0x9u: // C8 + entries = 256u; + break; + case 0xAu: // C14X2 + entries = 16384u; + break; + default: + return true; + } + + const std::uint16_t tmem_offset = + state_.texture_tlut_tmem_offset[slot]; + // A FIFO LOAD_TLUT1 is newer than the initial snapshot and already points at + // the exact MEM1 DMA source. Never replace that live mapping with stale + // capture-start bytes. + const DolGxRecompTlut& existing = state_.tmem_tluts[tmem_offset]; + if (existing.valid && existing.physical_base < kTmemSnapshotAddressBase) + return true; + // Dolphin's texMem snapshot indexes the BP TLUT offset directly in 512-byte + // units (the hardware-visible 0x80000 base is not present in the byte array). + const std::uint32_t byte_offset = + static_cast(tmem_offset) * 512u; + const std::uint32_t byte_size = entries * 2u; + if (existing.valid && existing.byte_size >= byte_size) + return true; + if (byte_offset >= tmem_snapshot_size_ || + byte_size > tmem_snapshot_size_ - byte_offset) + return true; + + DolGxRecompTlut tlut{ + .valid = true, + .slot = tmem_offset < DOL_GX_RECOMP_TLUT_SLOTS + ? static_cast(tmem_offset) + : static_cast(0xFFu), + .tmem_offset = tmem_offset, + .format = state_.texture_tlut_format[slot], + .entries = static_cast(entries), + .physical_base = kTmemSnapshotAddressBase + byte_offset, + .byte_size = byte_size, + .range = {}, + }; + state_.tmem_tluts[tmem_offset] = tlut; + return true; +} + bool RetailGxFrontend::maybe_resolve_texture(std::uint8_t slot) { if (slot >= DOL_GX_RECOMP_TEXTURE_SLOTS) return false; @@ -746,6 +814,8 @@ bool RetailGxFrontend::maybe_resolve_texture(std::uint8_t slot) { const std::uint8_t image3_reg = image3_reg_for_slot(slot); if (!state_.bp_valid[image0_reg] || !state_.bp_valid[image3_reg]) return true; + if (!seed_snapshot_tlut(slot)) + return false; DolGxRecompTexture texture; return dol_gx_recomp_resolve_texture_image( &state_, slot, state_.bp_regs[image0_reg], state_.bp_regs[image3_reg], diff --git a/graphics/frontend/src/trace_io.cpp b/graphics/frontend/src/trace_io.cpp index 1385e7a..e54303f 100644 --- a/graphics/frontend/src/trace_io.cpp +++ b/graphics/frontend/src/trace_io.cpp @@ -121,6 +121,12 @@ void TraceWriter::mem_update(std::uint32_t guest_addr, const void* bytes, write_record(RecordKind::MemUpdate, p, sizeof p, bytes, byte_size); } +void TraceWriter::tmem_snapshot(const void* bytes, std::uint32_t byte_size) { + std::uint8_t p[4]; + store_u32le(p, byte_size); + write_record(RecordKind::TmemSnapshot, p, sizeof p, bytes, byte_size); +} + void TraceWriter::present_stats(const PresentStats& stats) { std::uint8_t p[40]; const std::uint32_t fields[10] = { @@ -259,6 +265,17 @@ bool decode_mem_update(const RecordView& r, std::uint32_t& guest_addr, return true; } +bool decode_tmem_snapshot(const RecordView& r, + std::span& bytes) { + if (r.kind != RecordKind::TmemSnapshot || r.payload.size() < 4) + return false; + const std::uint32_t byte_size = load_u32le(r.payload.data()); + if (r.payload.size() - 4 != byte_size) + return false; + bytes = r.payload.subspan(4); + return true; +} + bool decode_present_stats(const RecordView& r, PresentStats& out) { if (r.kind != RecordKind::PresentStats || r.payload.size() != 40) return false; diff --git a/graphics/frontend/tests/dff2dolt_test.cpp b/graphics/frontend/tests/dff2dolt_test.cpp index 7438a3f..8e9b07f 100644 --- a/graphics/frontend/tests/dff2dolt_test.cpp +++ b/graphics/frontend/tests/dff2dolt_test.cpp @@ -81,10 +81,11 @@ struct DffFrameSpec { // Serializes a v6 FifoDataFile: 128-byte header, frame list, BP/CP/XF/XFRegs // snapshots, then per-frame fifoData + memory-update lists (the same shapes -// FifoDataFile::Save writes; TMEM omitted via texMemSize=0). +// FifoDataFile::Save writes). std::vector build_dff(const std::vector& frames, const std::uint32_t* cp_snapshot, - const std::uint32_t* xf_regs_snapshot) { + const std::uint32_t* xf_regs_snapshot, + const std::vector& tmem = {}) { std::vector out; out.resize(128u, 0u); // header patched at the end const std::size_t frame_list_offset = out.size(); @@ -102,6 +103,8 @@ std::vector build_dff(const std::vector& frames, const std::size_t xf_regs_offset = out.size(); for (std::uint32_t i = 0; i < 88u; ++i) put_le32(out, xf_regs_snapshot[i]); + const std::size_t tmem_offset = out.size(); + out.insert(out.end(), tmem.begin(), tmem.end()); for (std::size_t f = 0; f < frames.size(); ++f) { const DffFrameSpec& frame = frames[f]; @@ -150,8 +153,8 @@ std::vector build_dff(const std::vector& frames, patch_le64(out, 60u, frame_list_offset); patch_le32(out, 68u, static_cast(frames.size())); patch_le32(out, 72u, 0u); // flags (GC) - patch_le64(out, 76u, 0u); // texMemOffset - patch_le32(out, 84u, 0u); // texMemSize (no TMEM snapshot) + patch_le64(out, 76u, tmem.empty() ? 0u : tmem_offset); + patch_le32(out, 84u, static_cast(tmem.size())); patch_le32(out, 88u, kMem1Retail); // mem1_size patch_le32(out, 92u, 0x04000000u); // mem2_size std::memcpy(out.data() + 96u, "TESTDFF0", 8u); @@ -272,6 +275,41 @@ void test_exram_update_skipped(const char* dolt_path) { assert(result.frames[0].draws == 1u); } +void test_tmem_snapshot_preserved(const char* dolt_path) { + std::uint32_t cp[256]; + std::uint32_t xf_regs[88]; + snapshot_regs(cp, xf_regs); + + std::vector frames(1u); + frames[0].fifo = build_draw_fifo(); + const std::vector tmem = { + 0x00u, 0x11u, 0x00u, 0x33u, 0x44u, 0x00u, 0x66u, 0x77u, + }; + const std::vector dff_bytes = + build_dff(frames, cp, xf_regs, tmem); + + dff::ConvertStats stats; + std::string error; + assert(dff::convert(dff_bytes.data(), dff_bytes.size(), dolt_path, + dff::ConvertOptions{}, &stats, &error)); + assert(stats.tmem_snapshot_bytes == tmem.size()); + assert(stats.tmem_nonzero_bytes == 5u); + + trace::TraceReader reader; + assert(reader.open(dolt_path)); + trace::RecordView record; + assert(reader.next(record)); + std::span restored; + assert(trace::decode_tmem_snapshot(record, restored)); + assert(restored.size() == tmem.size()); + assert(std::memcmp(restored.data(), tmem.data(), tmem.size()) == 0); + + reader.rewind(); + const replay::ReplayResult result = replay::replay_trace(reader); + assert(result.parse_ok); + assert(result.frames.size() == 1u); +} + void test_malformed_inputs() { dff::ConvertStats stats; std::string error; @@ -299,11 +337,14 @@ void test_malformed_inputs() { int main() { const char* fixture_path = "dff2dolt_fixture.dolt"; const char* exram_path = "dff2dolt_exram.dolt"; + const char* tmem_path = "dff2dolt_tmem.dolt"; test_two_frame_conversion(fixture_path); test_exram_update_skipped(exram_path); + test_tmem_snapshot_preserved(tmem_path); test_malformed_inputs(); std::remove(fixture_path); std::remove(exram_path); + std::remove(tmem_path); std::printf("dff2dolt_tests passed\n"); return 0; } diff --git a/graphics/frontend/tests/frontend_replay_test.cpp b/graphics/frontend/tests/frontend_replay_test.cpp index f91697d..8bc26d3 100644 --- a/graphics/frontend/tests/frontend_replay_test.cpp +++ b/graphics/frontend/tests/frontend_replay_test.cpp @@ -477,9 +477,8 @@ void test_unresolved_tlut_load_is_noop() { assert(saw_packet(sink, DOL_GX_RECOMP_EVENT_BP_REG)); assert(!saw_packet(sink, DOL_GX_RECOMP_EVENT_TLUT)); - // Converted .dff captures can also start from a TMEM snapshot without the - // paired LOAD_TLUT0 base register in the FIFO preamble. Keep decoding and - // leave the TLUT resource absent until snapshot restore exists. + // A raw FIFO without a restored TMEM snapshot can start without the paired + // LOAD_TLUT0 base register. Keep decoding and leave the TLUT absent. fifo.clear(); push_bp(fifo, DOL_GX_BP_REG_LOAD_TLUT1, 0x00010300u); RetailGxFrontend missing_base_frontend; @@ -618,6 +617,39 @@ int main() { cpu.ram[copy_base + i] = static_cast(0x60u + i); } + // A DFF TMEM snapshot is restored before the synthesized BP preamble. The + // texture image state then identifies a C4 palette at BP TMEM offset 0x200; + // no LOAD_TLUT0/1 DMA command exists in the preamble, so the palette must be + // backed by the replay-only TMEM aperture. + { + RetailGxFrontend snapshot_frontend(resolver); + assert(snapshot_frontend.restore_tmem_snapshot(0x100000u)); + std::vector snapshot_fifo; + push_bp(snapshot_fifo, DOL_GX_BP_REG_TX_SETIMAGE0, + tex_image0(8u, 8u, 0x8u)); + push_bp(snapshot_fifo, DOL_GX_BP_REG_TX_SETIMAGE3, texture_base >> 5u); + push_bp(snapshot_fifo, DOL_GX_BP_REG_TX_SETTLUT, + 0x200u | (2u << 10u)); + RecordingAuroraRenderSink snapshot_sink; + assert(snapshot_frontend.replay_fifo(snapshot_fifo, &snapshot_sink)); + const auto& t = snapshot_frontend.state().tmem_tluts[0x200u]; + assert(t.valid); + assert(t.entries == 16u); + assert(t.format == 2u); + assert(t.physical_base == + dolruntime::aurora_recomp::kTmemSnapshotAddressBase + 0x40000u); + bool saw_snapshot_palette = false; + for (const auto& packet : snapshot_sink.packets()) { + if (packet.kind == RenderPacketKind::Resource && + packet.resource.kind == RenderResourceKind::Texture && + packet.resource.tlut_address == t.physical_base && + packet.resource.tlut_entries == 16u) { + saw_snapshot_palette = true; + } + } + assert(saw_snapshot_palette); + } + std::vector display_list; push_bp(display_list, DOL_GX_BP_REG_GENMODE, 3u << 14u); push_bp(display_list, DOL_GX_BP_REG_LOAD_TLUT0, tlut_base >> 5u); diff --git a/graphics/frontend/tests/trace_io_test.cpp b/graphics/frontend/tests/trace_io_test.cpp index 169074d..ac1024e 100644 --- a/graphics/frontend/tests/trace_io_test.cpp +++ b/graphics/frontend/tests/trace_io_test.cpp @@ -48,6 +48,8 @@ void write_reference_trace(const char* path, std::uint64_t* records_out) { writer.mem_update(0x80440000u, nullptr, 0); // 0-byte update is legal const auto big = pattern_bytes(70001, 0x42); // > 64 KB payload writer.mem_update(0x80500000u, big.data(), (std::uint32_t)big.size()); + const auto tmem = pattern_bytes(1024, 0x71); + writer.tmem_snapshot(tmem.data(), static_cast(tmem.size())); PresentStats stats{}; stats.frame_index = 60; stats.queued_pipelines = 1; @@ -115,6 +117,13 @@ void check_reference_records(TraceReader& reader) { const auto big = pattern_bytes(70001, 0x42); assert(std::memcmp(bytes.data(), big.data(), big.size()) == 0); + have = reader.next(r); + assert(have); + assert(decode_tmem_snapshot(r, bytes)); + const auto tmem = pattern_bytes(1024, 0x71); + assert(bytes.size() == tmem.size()); + assert(std::memcmp(bytes.data(), tmem.data(), tmem.size()) == 0); + have = reader.next(r); assert(have); PresentStats stats{}; @@ -127,7 +136,7 @@ void check_reference_records(TraceReader& reader) { void test_round_trip() { std::uint64_t written = 0; write_reference_trace(kPath, &written); - assert(written == 9); + assert(written == 10); TraceReader reader; const bool opened = reader.open(kPath); @@ -170,7 +179,7 @@ void test_truncated_tail() { while (reader.next(r)) ++complete; assert(reader.truncated()); - assert(complete == 8); // every record before the cut one + assert(complete == 9); // every record before the cut one } void test_bad_header() { diff --git a/graphics/frontend/tools/dff2dolt.cpp b/graphics/frontend/tools/dff2dolt.cpp index 47d9e09..8e196be 100644 --- a/graphics/frontend/tools/dff2dolt.cpp +++ b/graphics/frontend/tools/dff2dolt.cpp @@ -57,7 +57,8 @@ int main(int argc, char** argv) { std::fprintf(stderr, "dff2dolt: %s -> %s game_id=%s dff_v%u frames=%u " "fifo_bytes=%llu gx_records=%llu mem_updates=%u " - "(%llu bytes) preamble bp=%u cp=%u xf_words=%u xf_regs=%u\n", + "(%llu bytes) tmem=%u bytes preamble bp=%u cp=%u xf_words=%u " + "xf_regs=%u\n", in_path, out_path, stats.game_id[0] != '\0' ? stats.game_id : "(unset)", stats.dff_version, stats.frames, @@ -65,15 +66,11 @@ int main(int argc, char** argv) { static_cast(stats.gx_records), stats.mem_updates, static_cast(stats.mem_update_bytes), + stats.tmem_snapshot_bytes, stats.preamble_bp_regs, stats.preamble_cp_regs, stats.preamble_xf_words, stats.preamble_xf_regs); if (stats.skipped_exram_updates != 0u) std::fprintf(stderr, "dff2dolt: WARNING skipped %u EXRAM memory updates\n", stats.skipped_exram_updates); - if (stats.tmem_nonzero_bytes != 0u) - std::fprintf(stderr, - "dff2dolt: note: TMEM snapshot has %u nonzero bytes that are " - "NOT restored (v1 gap; see dff2dolt.hpp)\n", - stats.tmem_nonzero_bytes); return 0; } diff --git a/graphics/frontend/tools/dolgx_replay.cpp b/graphics/frontend/tools/dolgx_replay.cpp index 1246fa7..1c3551e 100644 --- a/graphics/frontend/tools/dolgx_replay.cpp +++ b/graphics/frontend/tools/dolgx_replay.cpp @@ -9,26 +9,36 @@ #include "dolgx_replay_pixels.hpp" +#include +#include +#include #include #include #include +#include #include +#include #include +#include #include #include #include namespace { -int usage(std::FILE* out) { +int usage(std::FILE *out) { std::fprintf( out, "usage: dolgx_replay [options]\n" " --against-stats gate replay digests against the trace's own\n" - " PRESENT_STATS (draws exact + vert-extent band;\n" + " PRESENT_STATS (draws exact + vert-extent " + "band;\n" " fails on >2 consecutive mismatch frames)\n" - " --digest exact line-compare against a golden digest file\n" + " --digest exact line-compare against a golden digest " + "file\n" " --write-digest write the digest lines to \n" + " --write-parity-jsonl \n" + " write gc_gx_parity_trace_v1 frame/draw rows\n" " --quiet suppress per-frame digest lines on stdout\n" " --histogram print decode-event histograms (BP/CP/XF regs,\n" " TEV/genMode configs, tex/tlut/copy formats,\n" @@ -74,8 +84,546 @@ struct Histogram { std::uint64_t copy_to_xfb = 0; }; -void histogram_observe(const DolGxRecompTraceEvent& event, void* user) { - auto* h = static_cast(user); +constexpr std::uint64_t kParityFnvBasis = 0xCBF29CE484222325ull; +constexpr std::uint64_t kParityFnvPrime = 1099511628211ull; + +std::uint64_t parity_fnv(const void *data, std::size_t size) { + auto hash = kParityFnvBasis; + const auto *bytes = static_cast(data); + for (std::size_t i = 0; i < size; ++i) { + hash ^= bytes[i]; + hash *= kParityFnvPrime; + } + return hash; +} + +std::string json_string(const std::string &value) { + std::ostringstream out; + out << '"'; + for (const unsigned char c : value) { + switch (c) { + case '"': + out << "\\\""; + break; + case '\\': + out << "\\\\"; + break; + case '\b': + out << "\\b"; + break; + case '\f': + out << "\\f"; + break; + case '\n': + out << "\\n"; + break; + case '\r': + out << "\\r"; + break; + case '\t': + out << "\\t"; + break; + default: + if (c < 0x20u) { + out << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(c) << std::dec; + } else { + out << c; + } + } + } + out << '"'; + return out.str(); +} + +std::string hex64(std::uint64_t value) { + std::ostringstream out; + out << "0x" << std::hex << std::uppercase << std::setw(16) + << std::setfill('0') << value; + return out.str(); +} + +struct ParityFrameRange { + std::uint32_t frame = 0; + unsigned long long first_draw = 0; + unsigned long long last_draw = 0; + std::uint32_t draw_count = 0; +}; + +struct ParityWriter { + struct DrawState { + std::array bp{}; + std::array bp_valid{}; + std::array vcd{}; + std::array vcd_valid{}; + std::array, 8> vat{}; + std::array, 8> vat_valid{}; + }; + + struct GeometryObservations { + bool valid = false; + std::array object_min{}; + std::array object_max{}; + std::vector> world_samples; + std::vector> clip_samples; + }; + + std::ofstream out; + DrawState state; + std::deque pending_draw_states; + std::vector frames; + + ParityWriter(const char *path, const char *trace_path, const char *game_id) + : out(path, std::ios::trunc) { + if (out) { + out << "{\"record\":\"meta\",\"schema\":\"gc_gx_parity_trace_v1\"," + "\"game_id\":" + << json_string(game_id) + << ",\"source\":{\"backend\":\"gxruntime_dolt_replay\",\"path\":" + << json_string(trace_path) << "}}\n"; + } + } + + void observe_event(const DolGxRecompTraceEvent &event) { + if (event.kind == DOL_GX_RECOMP_EVENT_BP_REG && event.a < state.bp.size()) { + state.bp[event.a] = event.b; + state.bp_valid[event.a] = true; + } else if (event.kind == DOL_GX_RECOMP_EVENT_CP_VCD && + event.a < state.vcd.size()) { + state.vcd[event.a] = event.b; + state.vcd_valid[event.a] = true; + } else if (event.kind == DOL_GX_RECOMP_EVENT_CP_VAT && + event.a < state.vat.size() && + event.b < state.vat[event.a].size()) { + state.vat[event.a][event.b] = event.c; + state.vat_valid[event.a][event.b] = true; + } else if (event.kind == DOL_GX_RECOMP_EVENT_DRAW) { + // ConsumingAuroraRenderSink completes a draw when the following draw + // arrives. Queue the command-boundary state so the delayed draw callback + // cannot accidentally observe BP/CP writes belonging to its successor. + pending_draw_states.push_back(state); + } + } + + static void nullable_u32(std::ostream &stream, const DrawState &draw_state, + std::uint32_t index) { + if (index < draw_state.bp.size() && draw_state.bp_valid[index]) + stream << draw_state.bp[index]; + else + stream << "null"; + } + + static void bp_array(std::ostream &stream, const DrawState &draw_state, + std::uint32_t first, std::uint32_t stride, + std::uint32_t count) { + for (std::uint32_t i = 0; i < count; ++i) { + const std::uint32_t index = first + i * stride; + if (index >= draw_state.bp.size() || !draw_state.bp_valid[index]) { + stream << "null"; + return; + } + } + stream << '['; + for (std::uint32_t i = 0; i < count; ++i) { + if (i != 0) + stream << ','; + stream << draw_state.bp[first + i * stride]; + } + stream << ']'; + } + + static void float_array(std::ostream &stream, const float *values, + std::size_t count) { + stream << '[' << std::setprecision(9); + for (std::size_t i = 0; i < count; ++i) { + if (i != 0) + stream << ','; + stream << values[i]; + } + stream << ']'; + } + + template + static void double_rows(std::ostream &stream, + const std::vector> &rows) { + stream << '['; + for (std::size_t i = 0; i < rows.size(); ++i) { + if (i != 0) + stream << ','; + stream << '[' << std::setprecision(17); + for (std::size_t component = 0; component < N; ++component) { + if (component != 0) + stream << ','; + stream << rows[i][component]; + } + stream << ']'; + } + stream << ']'; + } + + template + static void double_array(std::ostream &stream, + const std::array &values) { + stream << '[' << std::setprecision(17); + for (std::size_t i = 0; i < N; ++i) { + if (i != 0) + stream << ','; + stream << values[i]; + } + stream << ']'; + } + + static std::uint32_t component_size(std::uint32_t type) { + if (type <= 1u) + return 1u; + if (type <= 3u) + return 2u; + if (type <= 5u) + return 4u; + return 0u; + } + + static bool decode_component(const std::uint8_t *data, + std::size_t available, std::uint32_t type, + std::uint32_t fraction, float *out) { + const std::uint32_t size = component_size(type); + if (out == nullptr || size == 0u || available < size) + return false; + const float scale = fraction == 0u ? 1.0f : std::ldexp(1.0f, fraction); + switch (type) { + case 0u: + *out = static_cast(data[0]) / scale; + break; + case 1u: + *out = static_cast(static_cast(data[0])) / scale; + break; + case 2u: + *out = static_cast((static_cast(data[0]) << 8u) | + static_cast(data[1])) / + scale; + break; + case 3u: { + const std::uint16_t bits = + static_cast((static_cast(data[0]) << 8u) | + static_cast(data[1])); + *out = static_cast(static_cast(bits)) / scale; + break; + } + case 4u: + case 5u: { + const std::uint32_t bits = + (static_cast(data[0]) << 24u) | + (static_cast(data[1]) << 16u) | + (static_cast(data[2]) << 8u) | + static_cast(data[3]); + std::memcpy(out, &bits, sizeof bits); + break; + } + default: + return false; + } + return std::isfinite(*out); + } + + static GeometryObservations + observe_geometry(const DrawState &draw_state, + const dolruntime::aurora_recomp::ConsumedDraw &draw) { + GeometryObservations result; + if (draw.vtx_fmt >= draw_state.vat.size() || + !draw_state.vcd_valid[0] || + !draw_state.vat_valid[draw.vtx_fmt][0] || draw.vertex_size == 0u || + draw.vertex_count == 0u) + return result; + + const std::uint32_t vcd_lo = draw_state.vcd[0]; + if (((vcd_lo >> 9u) & 3u) != 1u) + return result; // DFF compact geometry currently exposes direct positions. + const std::uint32_t vat_a = draw_state.vat[draw.vtx_fmt][0]; + if ((vat_a & 1u) == 0u) + return result; // Keep XY draws unavailable on both compact trace sides. + const std::uint32_t position_type = (vat_a >> 1u) & 7u; + const std::uint32_t position_fraction = (vat_a >> 4u) & 0x1Fu; + const std::uint32_t value_size = component_size(position_type); + if (value_size == 0u) + return result; + std::uint32_t position_offset = 0u; + for (std::uint32_t attr = 0; attr < 9u; ++attr) + position_offset += (vcd_lo >> attr) & 1u; + const std::size_t required = + static_cast(position_offset) + 3u * value_size; + if (required > draw.vertex_size || + draw.vertex_payload.size() < + static_cast(draw.vertex_count) * draw.vertex_size) + return result; + if ((draw.transform_flags & + dolruntime::aurora_recomp::kDrawTransformProjectionValid) == 0u || + draw.current_pn_matrix >= DOL_GX_RECOMP_POSITION_MATRIX_COUNT || + (draw.position_matrix_valid_mask & (1u << draw.current_pn_matrix)) == + 0u) + return result; + + const float *matrix = draw.position_matrices[draw.current_pn_matrix]; + bool have_bounds = false; + for (std::uint32_t vertex = 0; vertex < draw.vertex_count; ++vertex) { + const std::size_t base = + static_cast(vertex) * draw.vertex_size + + position_offset; + std::array object{}; + for (std::uint32_t component = 0; component < object.size(); ++component) { + const std::size_t offset = base + component * value_size; + float decoded = 0.0f; + if (!decode_component(draw.vertex_payload.data() + offset, + draw.vertex_payload.size() - offset, + position_type, position_fraction, + &decoded)) + return GeometryObservations{}; + object[component] = decoded; + } + if (!have_bounds) { + result.object_min = object; + result.object_max = object; + have_bounds = true; + } else { + for (std::size_t axis = 0; axis < object.size(); ++axis) { + result.object_min[axis] = + std::min(result.object_min[axis], object[axis]); + result.object_max[axis] = + std::max(result.object_max[axis], object[axis]); + } + } + if (result.world_samples.size() >= 4u) + continue; + const std::array world = { + matrix[0] * object[0] + matrix[1] * object[1] + + matrix[2] * object[2] + matrix[3], + matrix[4] * object[0] + matrix[5] * object[1] + + matrix[6] * object[2] + matrix[7], + matrix[8] * object[0] + matrix[9] * object[1] + + matrix[10] * object[2] + matrix[11], + }; + std::array clip{}; + if (draw.projection_type == 0u) { + clip = { + draw.projection[0] * world[0] + draw.projection[1] * world[2], + draw.projection[2] * world[1] + draw.projection[3] * world[2], + draw.projection[4] * world[2] + draw.projection[5], -world[2]}; + } else { + clip = {draw.projection[0] * world[0] + draw.projection[1], + draw.projection[2] * world[1] + draw.projection[3], + draw.projection[4] * world[2] + draw.projection[5], 1.0f}; + } + result.world_samples.push_back(world); + result.clip_samples.push_back(clip); + } + result.valid = have_bounds; + return result; + } + + void observe_draw(std::uint32_t frame, std::uint32_t frame_draw, + const dolruntime::aurora_recomp::ConsumedDraw &draw, + unsigned long long cumulative_draw) { + if (!out) + return; + const DrawState draw_state = + pending_draw_states.empty() ? state : pending_draw_states.front(); + if (!pending_draw_states.empty()) + pending_draw_states.pop_front(); + if (frames.empty() || frames.back().frame != frame) { + frames.push_back({.frame = frame, + .first_draw = cumulative_draw, + .last_draw = cumulative_draw, + .draw_count = 0}); + } + frames.back().last_draw = cumulative_draw; + ++frames.back().draw_count; + + const bool gen_valid = draw_state.bp_valid[0x00u]; + const std::uint32_t gen = draw_state.bp[0x00u]; + const bool texture_order_valid = draw_state.bp_valid[0x28u]; + const std::uint32_t texture_slot = + texture_order_valid ? (draw_state.bp[0x28u] & 7u) : 0u; + const bool texture_enabled = + texture_order_valid && ((draw_state.bp[0x28u] >> 6u) & 1u) != 0u; + const auto &texture = + texture_slot < dolruntime::aurora_recomp::ConsumedDraw::kMaxTexmaps + ? draw.textures[texture_slot] + : draw.texture; + const GeometryObservations geometry = observe_geometry(draw_state, draw); + std::string texture_hash; + if (texture_enabled && texture.resolved && texture.host_data != nullptr && + texture.size <= texture.host_available) { + texture_hash = hex64(parity_fnv(texture.host_data, texture.size)); + } + std::string payload_hash = hex64( + parity_fnv(draw.vertex_payload.data(), draw.vertex_payload.size())); + + out << "{\"record\":\"draw\",\"ordinal\":" << cumulative_draw + << ",\"source_draw\":" << cumulative_draw + << ",\"source_frame\":" << frame << ",\"frame_ordinal\":" << frame + << ",\"frame_draw\":" << frame_draw << ",\"copy_epoch\":" << frame + << ",\"epoch_draw\":" << frame_draw + << ",\"command\":{\"primitive\":" << ((draw.primitive >> 3u) & 7u) + << ",\"vtxfmt\":" << draw.vtx_fmt << ",\"nverts\":" << draw.vertex_count + << ",\"stride\":" << draw.vertex_size + << "},\"gx_state\":{\"raw\":{" + "\"gen_mode\":"; + nullable_u32(out, draw_state, 0x00u); + out << ",\"blend_mode\":"; + nullable_u32(out, draw_state, 0x41u); + out << ",\"z_mode\":"; + nullable_u32(out, draw_state, 0x40u); + out << ",\"alpha_test\":"; + nullable_u32(out, draw_state, 0xF3u); + out << ",\"scissor_tl\":"; + nullable_u32(out, draw_state, 0x20u); + out << ",\"scissor_br\":"; + nullable_u32(out, draw_state, 0x21u); + out << "},\"tev_stages\":"; + if (gen_valid) + out << (((gen >> 10u) & 0xFu) + 1u); + else + out << "null"; + out << ",\"cull_mode\":"; + if (gen_valid) + out << ((gen >> 14u) & 3u); + else + out << "null"; + out << ",\"vcd_lo\":" + << (draw_state.vcd_valid[0] ? std::to_string(draw_state.vcd[0]) + : "null") + << ",\"vcd_hi\":" + << (draw_state.vcd_valid[1] ? std::to_string(draw_state.vcd[1]) + : "null") + << ",\"vat_a\":" + << (draw.vtx_fmt < draw_state.vat.size() && + draw_state.vat_valid[draw.vtx_fmt][0] + ? std::to_string(draw_state.vat[draw.vtx_fmt][0]) + : "null") + << ",\"vat_b\":" + << (draw.vtx_fmt < draw_state.vat.size() && + draw_state.vat_valid[draw.vtx_fmt][1] + ? std::to_string(draw_state.vat[draw.vtx_fmt][1]) + : "null") + << ",\"vat_c\":" + << (draw.vtx_fmt < draw_state.vat.size() && + draw_state.vat_valid[draw.vtx_fmt][2] + ? std::to_string(draw_state.vat[draw.vtx_fmt][2]) + : "null") + << "},\"texture\":{\"enabled\":" + << (texture_enabled ? "true" : "false"); + if (texture_enabled) { + out << ",\"slot\":" << texture_slot + << ",\"address_phys\":" << (texture.address & 0x03FFFFFFu) + << ",\"format\":" << texture.format << ",\"width\":" << texture.width + << ",\"height\":" << texture.height << ",\"source_hash\":" + << (texture_hash.empty() ? "null" : json_string(texture_hash)) + << ",\"tlut\":" + << (texture.has_tlut + ? std::to_string(texture.tlut_address & 0x03FFFFFFu) + : "null"); + } + out << "},\"matrix\":{\"projection_type\":"; + if ((draw.transform_flags & + dolruntime::aurora_recomp::kDrawTransformProjectionValid) != 0u) { + out << draw.projection_type << ",\"projection_coefficients\":"; + float_array(out, draw.projection, 6); + } else { + out << "null,\"projection_coefficients\":null"; + } + // DFF stores the XF position-matrix index as a raw word offset. The + // replay frontend uses a normalized matrix slot internally, so convert it + // back here to keep the parity trace's wire format source-independent. + out << ",\"position_index\":" << (draw.current_pn_matrix * 3u) + << ",\"position_values\":"; + if (draw.current_pn_matrix < DOL_GX_RECOMP_POSITION_MATRIX_COUNT && + (draw.position_matrix_valid_mask & (1u << draw.current_pn_matrix)) != + 0u) { + float_array(out, draw.position_matrices[draw.current_pn_matrix], + DOL_GX_RECOMP_POSITION_MATRIX_WORDS); + } else { + out << "null"; + } + out << "},\"vertices\":{\"payload_hash\":" << json_string(payload_hash); + if (geometry.valid) { + out << ",\"object_bounds\":{\"min\":"; + double_array(out, geometry.object_min); + out << ",\"max\":"; + double_array(out, geometry.object_max); + out << "},\"world_samples\":"; + double_rows(out, geometry.world_samples); + } + out << "},\"post_clip\":{"; + if (geometry.valid) { + out << "\"clip_samples\":"; + double_rows(out, geometry.clip_samples); + out << ",\"projection_type\":" << draw.projection_type + << ",\"derived_from\":\"gxruntime_direct_vertices\""; + } + out << "},\"fragment\":{\"tev_color_env\":"; + const std::uint32_t tev_stages = + gen_valid ? (((gen >> 10u) & 0xFu) + 1u) : 0u; + bp_array(out, draw_state, 0xC0u, 2u, tev_stages); + out << ",\"tev_alpha_env\":"; + bp_array(out, draw_state, 0xC1u, 2u, tev_stages); + out << ",\"tev_regs\":"; + bp_array(out, draw_state, 0xE0u, 1u, 8u); + out << ",\"tev_ksel\":"; + bp_array(out, draw_state, 0xF6u, 1u, 8u); + out << "},\"pixels\":{}}\n"; + } + + void + write_frames(const std::vector + &digests) { + for (std::size_t ordinal = 0; ordinal < digests.size(); ++ordinal) { + const auto &digest = digests[ordinal]; + const auto found = + std::find_if(frames.begin(), frames.end(), [&](const auto &range) { + return range.frame == digest.frame_index; + }); + const unsigned long long first = + found != frames.end() ? found->first_draw : 0; + const unsigned long long last = + found != frames.end() ? found->last_draw : 0; + const std::uint32_t count = found != frames.end() ? found->draw_count : 0; + out << "{\"record\":\"frame\",\"ordinal\":" << ordinal + << ",\"source_frame\":" << digest.frame_index + << ",\"copy_epoch\":" << digest.frame_index + << ",\"draw_start\":" << first << ",\"draw_end\":" << last + << ",\"draw_count\":" << count + << ",\"unknown_opcodes\":0," + "\"gxruntime_content_hash\":" + << json_string(hex64(digest.content_fnv)) + << ",\"gxruntime_state_hash\":" + << json_string(hex64(digest.state_fnv)) << "}\n"; + } + } +}; + +struct CliObservers { + Histogram *histogram = nullptr; + ParityWriter *parity = nullptr; +}; + +void histogram_observe(const DolGxRecompTraceEvent &event, void *user); + +void cli_event_observer(const DolGxRecompTraceEvent &event, void *user) { + auto *observers = static_cast(user); + if (observers->histogram != nullptr) + histogram_observe(event, observers->histogram); + if (observers->parity != nullptr) + observers->parity->observe_event(event); +} + +void cli_draw_observer(std::uint32_t frame_index, std::uint32_t frame_draw, + const dolruntime::aurora_recomp::ConsumedDraw &draw, + unsigned long long cumulative_draw, void *user) { + static_cast(user)->observe_draw(frame_index, frame_draw, draw, + cumulative_draw); +} + +void histogram_observe(const DolGxRecompTraceEvent &event, void *user) { + auto *h = static_cast(user); switch (event.kind) { case DOL_GX_RECOMP_EVENT_BP_REG: { const std::uint32_t reg = event.a; @@ -136,33 +684,49 @@ void histogram_observe(const DolGxRecompTraceEvent& event, void* user) { } } -const char* tex_format_name(std::uint32_t format) { +const char *tex_format_name(std::uint32_t format) { switch (format) { - case 0x0: return "I4"; - case 0x1: return "I8"; - case 0x2: return "IA4"; - case 0x3: return "IA8"; - case 0x4: return "RGB565"; - case 0x5: return "RGB5A3"; - case 0x6: return "RGBA8"; - case 0x8: return "C4"; - case 0x9: return "C8"; - case 0xA: return "C14X2"; - case 0xE: return "CMPR"; - default: return "?"; + case 0x0: + return "I4"; + case 0x1: + return "I8"; + case 0x2: + return "IA4"; + case 0x3: + return "IA8"; + case 0x4: + return "RGB565"; + case 0x5: + return "RGB5A3"; + case 0x6: + return "RGBA8"; + case 0x8: + return "C4"; + case 0x9: + return "C8"; + case 0xA: + return "C14X2"; + case 0xE: + return "CMPR"; + default: + return "?"; } } -const char* tlut_format_name(std::uint32_t format) { +const char *tlut_format_name(std::uint32_t format) { switch (format) { - case 0x0: return "IA8"; - case 0x1: return "RGB565"; - case 0x2: return "RGB5A3"; - default: return "?"; + case 0x0: + return "IA8"; + case 0x1: + return "RGB565"; + case 0x2: + return "RGB5A3"; + default: + return "?"; } } -void print_histogram(const Histogram& h) { +void print_histogram(const Histogram &h) { std::printf("== histogram: draws %llu (verts %llu) display_lists %llu " "(%llu bytes) indexed_xf %llu indexed_spans %llu cull_all %llu\n", (unsigned long long)h.draws, (unsigned long long)h.draw_verts, @@ -174,50 +738,50 @@ void print_histogram(const Histogram& h) { std::printf("== copies %llu (clear %llu, to_xfb %llu) targets:", (unsigned long long)h.copies, (unsigned long long)h.copy_clears, (unsigned long long)h.copy_to_xfb); - for (const auto& [target, count] : h.copy_targets) + for (const auto &[target, count] : h.copy_targets) std::printf(" %u:%llu", target, (unsigned long long)count); std::printf("\n== tex formats:"); - for (const auto& [format, count] : h.tex_formats) - std::printf(" %s:%llu", tex_format_name(format), - (unsigned long long)count); + for (const auto &[format, count] : h.tex_formats) + std::printf(" %s:%llu", tex_format_name(format), (unsigned long long)count); std::printf("\n== tlut formats:"); - for (const auto& [format, count] : h.tlut_formats) + for (const auto &[format, count] : h.tlut_formats) std::printf(" %s:%llu", tlut_format_name(format), (unsigned long long)count); std::printf("\n== genMode: tev_stages"); - for (const auto& [stages, count] : h.tev_stage_counts) + for (const auto &[stages, count] : h.tev_stage_counts) std::printf(" %u:%llu", stages, (unsigned long long)count); std::printf(" | texgens"); - for (const auto& [texgens, count] : h.texgen_counts) + for (const auto &[texgens, count] : h.texgen_counts) std::printf(" %u:%llu", texgens, (unsigned long long)count); std::printf(" | ind_stages"); - for (const auto& [stages, count] : h.ind_stage_counts) + for (const auto &[stages, count] : h.ind_stage_counts) std::printf(" %u:%llu", stages, (unsigned long long)count); std::printf("\n== draw prims (prim/vtxfmt:count):"); - for (const auto& [key, count] : h.draw_prims) + for (const auto &[key, count] : h.draw_prims) std::printf(" %02X/%u:%llu", key & 0xFFu, key >> 8u, (unsigned long long)count); std::printf("\n== vcd_lo values:"); - for (const auto& [value, count] : h.cp_vcd_lo) + for (const auto &[value, count] : h.cp_vcd_lo) std::printf(" 0x%X:%llu", value, (unsigned long long)count); std::printf("\n== bp regs:"); - for (const auto& [reg, count] : h.bp_regs) + for (const auto &[reg, count] : h.bp_regs) std::printf(" %02X:%llu", reg, (unsigned long long)count); std::printf("\n== xf regs (0x1000+):"); - for (const auto& [reg, count] : h.xf_regs) + for (const auto &[reg, count] : h.xf_regs) std::printf(" %02X:%llu", reg, (unsigned long long)count); std::printf("\n== xf mem regions (base&0xFF00):"); - for (const auto& [base, count] : h.xf_mem_bases) + for (const auto &[base, count] : h.xf_mem_bases) std::printf(" %04X:%llu", base, (unsigned long long)count); std::printf("\n"); } } // namespace -int main(int argc, char** argv) { - const char* trace_path = nullptr; - const char* golden_path = nullptr; - const char* write_path = nullptr; +int main(int argc, char **argv) { + const char *trace_path = nullptr; + const char *golden_path = nullptr; + const char *write_path = nullptr; + const char *parity_path = nullptr; bool against_stats = false; bool quiet = false; bool histogram = false; @@ -226,7 +790,7 @@ int main(int argc, char** argv) { PixelReplayOptions pixel_options; for (int i = 1; i < argc; ++i) { - const char* arg = argv[i]; + const char *arg = argv[i]; if (std::strcmp(arg, "--help") == 0) return usage(stdout); if (std::strcmp(arg, "--against-stats") == 0) { @@ -239,6 +803,8 @@ int main(int argc, char** argv) { golden_path = argv[++i]; } else if (std::strcmp(arg, "--write-digest") == 0 && i + 1 < argc) { write_path = argv[++i]; + } else if (std::strcmp(arg, "--write-parity-jsonl") == 0 && i + 1 < argc) { + parity_path = argv[++i]; } else if (std::strcmp(arg, "--pixels") == 0) { pixels = true; } else if (std::strcmp(arg, "--core") == 0) { @@ -263,15 +829,15 @@ int main(int argc, char** argv) { return usage(stderr); if (pixels || core) { - if (against_stats || golden_path != nullptr || write_path != nullptr) { + if (against_stats || golden_path != nullptr || write_path != nullptr || + parity_path != nullptr) { std::fprintf(stderr, "dolgx_replay: --pixels/--core do not combine with Mode A " "gates\n"); return usage(stderr); } if (pixels && core) { - std::fprintf(stderr, - "dolgx_replay: --pixels and --core are exclusive\n"); + std::fprintf(stderr, "dolgx_replay: --pixels and --core are exclusive\n"); return usage(stderr); } pixel_options.quiet = quiet; @@ -305,7 +871,7 @@ int main(int argc, char** argv) { std::fprintf(stderr, "dolgx_replay: cannot open trace %s\n", trace_path); return 2; } - const trace::TraceHeader& header = reader.header(); + const trace::TraceHeader &header = reader.header(); char game_id[9] = {}; std::memcpy(game_id, header.game_id, sizeof header.game_id); std::fprintf(stderr, "dolgx_replay: %s game_id=%s mem1=0x%08X version=%u\n", @@ -313,20 +879,33 @@ int main(int argc, char** argv) { header.mem1_size, reader.version()); Histogram hist; + std::unique_ptr parity; + if (parity_path != nullptr) { + parity = std::make_unique(parity_path, trace_path, + game_id[0] != '\0' ? game_id : ""); + if (!parity->out) { + std::fprintf(stderr, "dolgx_replay: cannot open %s\n", parity_path); + return 2; + } + } + CliObservers observers{ + .histogram = histogram ? &hist : nullptr, + .parity = parity.get(), + }; const replay::ReplayResult result = replay::replay_trace( - reader, histogram ? histogram_observe : nullptr, &hist); + reader, (histogram || parity) ? cli_event_observer : nullptr, &observers, + parity ? cli_draw_observer : nullptr, parity.get()); if (result.truncated) - std::fprintf(stderr, - "dolgx_replay: trace ends mid-record (interrupted " - "recording); replayed the complete prefix\n"); + std::fprintf(stderr, "dolgx_replay: trace ends mid-record (interrupted " + "recording); replayed the complete prefix\n"); std::vector lines; lines.reserve(result.frames.size()); - for (const replay::FrameDigest& frame : result.frames) + for (const replay::FrameDigest &frame : result.frames) lines.push_back(replay::format_digest_line(frame)); if (!quiet) - for (const std::string& line : lines) + for (const std::string &line : lines) std::printf("%s\n", line.c_str()); if (write_path != nullptr) { @@ -335,13 +914,23 @@ int main(int argc, char** argv) { std::fprintf(stderr, "dolgx_replay: cannot write %s\n", write_path); return 2; } - for (const std::string& line : lines) + for (const std::string &line : lines) out << line << '\n'; } if (histogram) print_histogram(hist); + if (parity) { + parity->write_frames(result.frames); + parity->out.flush(); + if (!parity->out) { + std::fprintf(stderr, "dolgx_replay: parity JSONL write failed: %s\n", + parity_path); + return 2; + } + } + if (!result.parse_ok) { std::fprintf(stderr, "dolgx_replay: FAIL %s\n", result.error.c_str()); return 1; @@ -356,8 +945,7 @@ int main(int argc, char** argv) { "worst_consecutive=%llu%s%s\n", cmp.ok ? "OK" : "FAIL", cmp.frames_compared, cmp.mismatch_frames, cmp.worst_consecutive, - cmp.detail.empty() ? "" : " first=", - cmp.detail.c_str()); + cmp.detail.empty() ? "" : " first=", cmp.detail.c_str()); if (!cmp.ok) exit_code = 1; } @@ -400,8 +988,7 @@ int main(int argc, char** argv) { digest_ok = false; } if (digest_ok) - std::fprintf(stderr, "dolgx_replay: digest OK (%zu lines)\n", - line_index); + std::fprintf(stderr, "dolgx_replay: digest OK (%zu lines)\n", line_index); else exit_code = 1; } diff --git a/graphics/frontend/tools/dolgx_replay_core.cpp b/graphics/frontend/tools/dolgx_replay_core.cpp index e6337a1..d8f5277 100644 --- a/graphics/frontend/tools/dolgx_replay_core.cpp +++ b/graphics/frontend/tools/dolgx_replay_core.cpp @@ -55,6 +55,7 @@ std::uint64_t fnv1a(const std::uint8_t* data, std::size_t size) { struct CoreContext { std::vector mem1; + std::vector tmem; unsigned long long submitted = 0; unsigned long long submit_rejected = 0; }; @@ -65,6 +66,21 @@ bool mem1_resolver(void* user, u32 address, u32 size, auto* ctx = static_cast(user); if (out == nullptr || size == 0u) return false; + if (resource == DOL_GUEST_RESOURCE_TLUT && + address >= ar::kTmemSnapshotAddressBase) { + const u32 offset = address - ar::kTmemSnapshotAddressBase; + if (offset >= ctx->tmem.size() || size > ctx->tmem.size() - offset) + return false; + *out = { + .data = ctx->tmem.data() + offset, + .address = address, + .size = size, + .available = static_cast(ctx->tmem.size() - offset), + .space = space, + .resource = resource, + }; + return true; + } const u32 physical = dol_gx_recomp_guest_to_physical(address); if (physical >= ctx->mem1.size() || size > ctx->mem1.size() - physical) return false; @@ -335,6 +351,21 @@ int dolgx_replay_core_main(const char* trace_path, std::memcpy(ctx.mem1.data() + physical, bytes.data(), bytes.size()); break; } + case trace::RecordKind::TmemSnapshot: { + std::span bytes; + if (!trace::decode_tmem_snapshot(record, bytes) || + bytes.size() > ar::kTmemSnapshotMaxBytes) { + fail("malformed TMEM_SNAPSHOT"); + break; + } + ctx.tmem.assign(bytes.begin(), bytes.end()); + if (!frontend.restore_tmem_snapshot( + static_cast(ctx.tmem.size())) || + !frontend.flush(&sink)) { + fail("frontend rejected TMEM snapshot"); + } + break; + } case trace::RecordKind::PresentStats: { present_and_capture(); break; From e5077220e0a17238735771ea4c86a60b695f1a5a Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 20:47:49 +0200 Subject: [PATCH 2/3] Make deep GX evidence deterministic across replay hosts Extend compact replay evidence with world, clip, UV, TLUT, and rejection observations while explicitly rounding binary32 transforms so compiler FMA contraction cannot invent a geometry divergence. Keep optional pixel replay at canonical EFB dimensions independent of Retina backing scale.\n\nConstraint: DFF and GXRuntime must derive identical evidence from identical FIFO state on different compilers and display scales.\nRejected: Widen float tolerances | it hides real geometry errors and leaves host-dependent evidence.\nConfidence: high\nScope-risk: moderate\nDirective: Preserve explicit binary32 operation ordering in compact geometry analysis.\nTested: Headless and Aurora dolgx_replay builds; 7/7 GXRuntime tests; 35,393 AC draws match the DFF analyzer trace exactly.\nNot-tested: Full canonical-EFB pixel replay under every supported host GPU. --- graphics/frontend/tools/dolgx_replay.cpp | 251 ++++++++++++++++-- .../frontend/tools/dolgx_replay_pixels.cpp | 28 +- 2 files changed, 250 insertions(+), 29 deletions(-) diff --git a/graphics/frontend/tools/dolgx_replay.cpp b/graphics/frontend/tools/dolgx_replay.cpp index 1c3551e..9131877 100644 --- a/graphics/frontend/tools/dolgx_replay.cpp +++ b/graphics/frontend/tools/dolgx_replay.cpp @@ -164,6 +164,16 @@ struct ParityWriter { bool valid = false; std::array object_min{}; std::array object_max{}; + std::array world_min{}; + std::array world_max{}; + std::array clip_min{}; + std::array clip_max{}; + std::array uv_min{}; + std::array uv_max{}; + std::uint64_t world_hash = 0; + std::uint64_t clip_hash = 0; + bool clip_rejected = false; + bool uv_valid = false; std::vector> world_samples; std::vector> clip_samples; }; @@ -325,6 +335,34 @@ struct ParityWriter { return std::isfinite(*out); } + // Keep the compact analyzer independent of host FMA contraction and loop + // unrolling. DFF's source-side adapter rounds every binary32 product and + // addition explicitly; doing the same here prevents identical GX state + // from producing lane-dependent clip bounds on different host compilers. + static float round_f32(float value) { + volatile float rounded = value; + return rounded; + } + + static float transform_row(const float *row, const std::array &v) { + const float p0 = round_f32(row[0] * v[0]); + const float p1 = round_f32(row[1] * v[1]); + const float p2 = round_f32(row[2] * v[2]); + const float s01 = round_f32(p0 + p1); + const float s012 = round_f32(s01 + p2); + return round_f32(s012 + row[3]); + } + + static float project_two(float a, float x, float b, float y) { + const float p0 = round_f32(a * x); + const float p1 = round_f32(b * y); + return round_f32(p0 + p1); + } + + static float project_bias(float a, float x, float bias) { + return round_f32(round_f32(a * x) + bias); + } + static GeometryObservations observe_geometry(const DrawState &draw_state, const dolruntime::aurora_recomp::ConsumedDraw &draw) { @@ -363,7 +401,58 @@ struct ParityWriter { return result; const float *matrix = draw.position_matrices[draw.current_pn_matrix]; + std::size_t tex0_offset = position_offset + 3u * value_size; + std::uint32_t tex0_type = 0u; + std::uint32_t tex0_fraction = 0u; + std::uint32_t tex0_components = 0u; + bool tex0_direct = false; + if (draw_state.vcd_valid[1]) { + const auto indexed_or_direct_size = [](std::uint32_t mode, + std::size_t direct_size) { + if (mode == 1u) return direct_size; + if (mode == 2u) return std::size_t{1}; + if (mode == 3u) return std::size_t{2}; + return std::size_t{0}; + }; + const std::uint32_t normal_mode = (vcd_lo >> 11u) & 3u; + if (normal_mode != 0u) { + const std::uint32_t normal_type = (vat_a >> 10u) & 7u; + const std::size_t normal_component_size = component_size(normal_type); + const std::size_t normal_components = ((vat_a >> 9u) & 1u) ? 9u : 3u; + tex0_offset += indexed_or_direct_size( + normal_mode, normal_component_size * normal_components); + } + const auto color_size = [](std::uint32_t format) -> std::size_t { + static constexpr std::size_t sizes[8] = {2u, 3u, 4u, 2u, + 3u, 4u, 0u, 0u}; + return sizes[format & 7u]; + }; + const std::uint32_t color0_mode = (vcd_lo >> 13u) & 3u; + const std::uint32_t color1_mode = (vcd_lo >> 15u) & 3u; + tex0_offset += indexed_or_direct_size( + color0_mode, color_size((vat_a >> 14u) & 7u)); + tex0_offset += indexed_or_direct_size( + color1_mode, color_size((vat_a >> 18u) & 7u)); + const std::uint32_t tex0_mode = draw_state.vcd[1] & 3u; + tex0_type = (vat_a >> 22u) & 7u; + tex0_fraction = (vat_a >> 25u) & 0x1Fu; + tex0_components = ((vat_a >> 21u) & 1u) ? 2u : 1u; + tex0_direct = tex0_mode == 1u && component_size(tex0_type) != 0u && + tex0_offset + tex0_components * component_size(tex0_type) <= + draw.vertex_size; + } bool have_bounds = false; + result.world_hash = 0xCBF29CE484222325ull; + result.clip_hash = 0xCBF29CE484222325ull; + auto mix_f32 = [](std::uint64_t &hash, float value) { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + for (unsigned shift = 0; shift < 32; shift += 8) { + hash ^= static_cast(bits >> shift); + hash *= 1099511628211ull; + } + }; + std::uint32_t shared_clip_mask = 0x3Fu; for (std::uint32_t vertex = 0; vertex < draw.vertex_count; ++vertex) { const std::size_t base = static_cast(vertex) * draw.vertex_size + @@ -379,6 +468,30 @@ struct ParityWriter { return GeometryObservations{}; object[component] = decoded; } + if (tex0_direct) { + std::array uv{}; + const std::size_t tex_component_size = component_size(tex0_type); + for (std::uint32_t component = 0; component < tex0_components; ++component) { + float decoded = 0.0f; + const std::size_t offset = + static_cast(vertex) * draw.vertex_size + tex0_offset + + component * tex_component_size; + if (!decode_component(draw.vertex_payload.data() + offset, + draw.vertex_payload.size() - offset, tex0_type, + tex0_fraction, &decoded)) + return GeometryObservations{}; + uv[component] = decoded; + } + if (!result.uv_valid) { + result.uv_min = result.uv_max = uv; + result.uv_valid = true; + } else { + for (std::size_t axis = 0; axis < uv.size(); ++axis) { + result.uv_min[axis] = std::min(result.uv_min[axis], uv[axis]); + result.uv_max[axis] = std::max(result.uv_max[axis], uv[axis]); + } + } + } if (!have_bounds) { result.object_min = object; result.object_max = object; @@ -391,30 +504,76 @@ struct ParityWriter { std::max(result.object_max[axis], object[axis]); } } - if (result.world_samples.size() >= 4u) - continue; - const std::array world = { - matrix[0] * object[0] + matrix[1] * object[1] + - matrix[2] * object[2] + matrix[3], - matrix[4] * object[0] + matrix[5] * object[1] + - matrix[6] * object[2] + matrix[7], - matrix[8] * object[0] + matrix[9] * object[1] + - matrix[10] * object[2] + matrix[11], + const std::array object_f = { + static_cast(object[0]), static_cast(object[1]), + static_cast(object[2])}; + const std::array world_f = { + transform_row(matrix + 0u, object_f), + transform_row(matrix + 4u, object_f), + transform_row(matrix + 8u, object_f), }; - std::array clip{}; + const std::array world = { + world_f[0], world_f[1], world_f[2]}; + std::array clip_f{}; if (draw.projection_type == 0u) { - clip = { - draw.projection[0] * world[0] + draw.projection[1] * world[2], - draw.projection[2] * world[1] + draw.projection[3] * world[2], - draw.projection[4] * world[2] + draw.projection[5], -world[2]}; + clip_f = { + project_two(draw.projection[0], world_f[0], draw.projection[1], + world_f[2]), + project_two(draw.projection[2], world_f[1], draw.projection[3], + world_f[2]), + project_bias(draw.projection[4], world_f[2], draw.projection[5]), + round_f32(-world_f[2])}; + } else { + clip_f = { + project_bias(draw.projection[0], world_f[0], draw.projection[1]), + project_bias(draw.projection[2], world_f[1], draw.projection[3]), + project_bias(draw.projection[4], world_f[2], draw.projection[5]), + 1.0f}; + } + const std::array clip = { + clip_f[0], clip_f[1], clip_f[2], clip_f[3]}; + for (float component : world_f) + mix_f32(result.world_hash, component); + for (float component : clip_f) + mix_f32(result.clip_hash, component); + result.clip_hash ^= 1u; + result.clip_hash *= 1099511628211ull; + if (vertex == 0u) { + result.world_min = result.world_max = world; + result.clip_min = result.clip_max = clip; } else { - clip = {draw.projection[0] * world[0] + draw.projection[1], - draw.projection[2] * world[1] + draw.projection[3], - draw.projection[4] * world[2] + draw.projection[5], 1.0f}; + for (std::size_t axis = 0; axis < world.size(); ++axis) { + result.world_min[axis] = std::min(result.world_min[axis], world[axis]); + result.world_max[axis] = std::max(result.world_max[axis], world[axis]); + } + for (std::size_t axis = 0; axis < clip.size(); ++axis) { + result.clip_min[axis] = std::min(result.clip_min[axis], clip[axis]); + result.clip_max[axis] = std::max(result.clip_max[axis], clip[axis]); + } + } + std::uint32_t mask = 0u; + if (clip_f[3] - clip_f[0] < 0.0f) mask |= 0x01u; + if (clip_f[0] + clip_f[3] < 0.0f) mask |= 0x02u; + if (clip_f[3] - clip_f[1] < 0.0f) mask |= 0x04u; + if (clip_f[1] + clip_f[3] < 0.0f) mask |= 0x08u; + if (draw.projection_type != 0u && clip_f[2] > 0.000001f) mask |= 0x10u; + if (clip_f[2] + clip_f[3] < -0.000001f) mask |= 0x20u; + shared_clip_mask &= mask; + if (result.world_samples.size() < 4u) { + result.world_samples.push_back(world); + result.clip_samples.push_back(clip); } - result.world_samples.push_back(world); - result.clip_samples.push_back(clip); } + const bool perspective = draw.projection_type == 0u; + const std::uint32_t z_raw = + draw_state.bp_valid[0x40u] ? draw_state.bp[0x40u] : 0u; + const bool hard_z = !perspective || (z_raw & 1u) != 0u || + (z_raw & 0x10u) != 0u; + const std::uint32_t hard_mask = (perspective ? 0x0Fu : 0u) | + (hard_z ? 0x30u : 0u); + result.clip_rejected = + (perspective && result.clip_max[3] <= 0.0) || + (shared_clip_mask & hard_mask) != 0u; result.valid = have_bounds; return result; } @@ -450,10 +609,18 @@ struct ParityWriter { : draw.texture; const GeometryObservations geometry = observe_geometry(draw_state, draw); std::string texture_hash; + std::string tlut_hash; if (texture_enabled && texture.resolved && texture.host_data != nullptr && texture.size <= texture.host_available) { texture_hash = hex64(parity_fnv(texture.host_data, texture.size)); } + const std::size_t tlut_size = + static_cast(texture.tlut_entries) * 2u; + if (texture_enabled && texture.has_tlut && + texture.tlut_host_data != nullptr && + tlut_size <= texture.tlut_host_available) { + tlut_hash = hex64(parity_fnv(texture.tlut_host_data, tlut_size)); + } std::string payload_hash = hex64( parity_fnv(draw.vertex_payload.data(), draw.vertex_payload.size())); @@ -474,6 +641,8 @@ struct ParityWriter { nullable_u32(out, draw_state, 0x40u); out << ",\"alpha_test\":"; nullable_u32(out, draw_state, 0xF3u); + out << ",\"pe_control\":"; + nullable_u32(out, draw_state, 0x43u); out << ",\"scissor_tl\":"; nullable_u32(out, draw_state, 0x20u); out << ",\"scissor_br\":"; @@ -517,10 +686,22 @@ struct ParityWriter { << ",\"format\":" << texture.format << ",\"width\":" << texture.width << ",\"height\":" << texture.height << ",\"source_hash\":" << (texture_hash.empty() ? "null" : json_string(texture_hash)) + << ",\"source_hash_raw\":" + << (texture_hash.empty() ? "null" : json_string(texture_hash)) << ",\"tlut\":" << (texture.has_tlut ? std::to_string(texture.tlut_address & 0x03FFFFFFu) - : "null"); + : "null") + << ",\"tlut_address_phys\":" + << (texture.has_tlut + ? std::to_string(texture.tlut_address & 0x03FFFFFFu) + : "null") + << ",\"tlut_format\":" + << (texture.has_tlut ? std::to_string(texture.tlut_format) : "null") + << ",\"tlut_entries\":" + << (texture.has_tlut ? std::to_string(texture.tlut_entries) : "null") + << ",\"tlut_source_hash\":" + << (tlut_hash.empty() ? "null" : json_string(tlut_hash)); } out << "},\"matrix\":{\"projection_type\":"; if ((draw.transform_flags & @@ -530,10 +711,9 @@ struct ParityWriter { } else { out << "null,\"projection_coefficients\":null"; } - // DFF stores the XF position-matrix index as a raw word offset. The - // replay frontend uses a normalized matrix slot internally, so convert it - // back here to keep the parity trace's wire format source-independent. - out << ",\"position_index\":" << (draw.current_pn_matrix * 3u) + // The parity schema uses a semantic 3x4 matrix slot. Keep GX's raw + // word-addressed PNMTX register encoding internal to the DFF decoder. + out << ",\"position_index\":" << draw.current_pn_matrix << ",\"position_values\":"; if (draw.current_pn_matrix < DOL_GX_RECOMP_POSITION_MATRIX_COUNT && (draw.position_matrix_valid_mask & (1u << draw.current_pn_matrix)) != @@ -549,12 +729,31 @@ struct ParityWriter { double_array(out, geometry.object_min); out << ",\"max\":"; double_array(out, geometry.object_max); - out << "},\"world_samples\":"; + out << "},\"world_bounds\":{\"min\":"; + double_array(out, geometry.world_min); + out << ",\"max\":"; + double_array(out, geometry.world_max); + out << "},\"world_hash\":" << json_string(hex64(geometry.world_hash)) + << ",\"world_samples\":"; double_rows(out, geometry.world_samples); + if (geometry.uv_valid) { + out << ",\"uv_bounds\":{\"min\":"; + double_array(out, geometry.uv_min); + out << ",\"max\":"; + double_array(out, geometry.uv_max); + out << "}"; + } } out << "},\"post_clip\":{"; if (geometry.valid) { - out << "\"clip_samples\":"; + out << "\"clip_hash\":" << json_string(hex64(geometry.clip_hash)) + << ",\"clip_bounds\":{\"min\":"; + double_array(out, geometry.clip_min); + out << ",\"max\":"; + double_array(out, geometry.clip_max); + out << "},\"clip_rejected\":" + << (geometry.clip_rejected ? "true" : "false") + << ",\"clip_samples\":"; double_rows(out, geometry.clip_samples); out << ",\"projection_type\":" << draw.projection_type << ",\"derived_from\":\"gxruntime_direct_vertices\""; diff --git a/graphics/frontend/tools/dolgx_replay_pixels.cpp b/graphics/frontend/tools/dolgx_replay_pixels.cpp index 97dc5c4..c2c296d 100644 --- a/graphics/frontend/tools/dolgx_replay_pixels.cpp +++ b/graphics/frontend/tools/dolgx_replay_pixels.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -60,6 +61,14 @@ struct PixelContext { std::vector mem1; }; +u32 pixel_window_dimension(const char* name, u32 fallback) { + const char* raw = std::getenv(name); + if (raw == nullptr || raw[0] == '\0') + return fallback; + const unsigned long parsed = std::strtoul(raw, nullptr, 0); + return parsed >= 64u && parsed <= 4096u ? static_cast(parsed) : fallback; +} + bool mem1_resolver(void* user, u32 address, u32 size, DolGuestAddressSpace, DolGuestResourceKind, const void** data, u32* available) { auto* ctx = static_cast(user); @@ -213,8 +222,8 @@ int dolgx_replay_pixels_main(const char* trace_path, // of each digest line. const AuroraBackendConfig config = { .app_name = "dolgx_replay", - .window_width = 1024, - .window_height = 768, + .window_width = pixel_window_dimension("DOLGX_PIXEL_WINDOW_WIDTH", 1024u), + .window_height = pixel_window_dimension("DOLGX_PIXEL_WINDOW_HEIGHT", 768u), .vsync = false, .allow_texture_dumps = false, .info_logging = false, @@ -227,6 +236,12 @@ int dolgx_replay_pixels_main(const char* trace_path, std::fprintf(stderr, "dolgx_replay: aurora initialization failed\n"); return 2; } + // Pixel parity needs an EFB-sized image, not a host-window/backing-scale + // dependent image (2048x1536 on a 2x Retina 1024x768 window). Aurora keeps + // the native presentation surface separate, so lock only the internal GX + // framebuffer to the canonical VI size when requested. + if (std::getenv("DOLGX_PIXEL_CANONICAL_EFB") != nullptr) + VISetFrameBufferScale(1.0f); // Canonical SDK baseline, exactly as a booting game establishes it before // any recording window opens. A windowed trace only carries the state its @@ -237,6 +252,12 @@ int dolgx_replay_pixels_main(const char* trace_path, // SDK draw, so flush explicitly. static std::vector gx_fifo(64u * 1024u); GXInit(gx_fifo.data(), static_cast(gx_fifo.size())); + if (std::getenv("DOLGX_PIXEL_CANONICAL_EFB") != nullptr) { + // VISetFrameBufferScale queues Aurora's resize for the next presentation. + // Consume that empty setup presentation before replay begins; otherwise + // frame 1 is hashed at the Retina backing size and frames 2+ at 640x480. + aurora_backend_present(); + } GXFlush(); std::vector lines; @@ -292,7 +313,8 @@ int dolgx_replay_pixels_main(const char* trace_path, if (!options.quiet) std::printf("%s\n", line); if (options.png_dir != nullptr && - (options.png_every <= 1u || current_frame % options.png_every == 0u)) { + (current_frame == 1u || options.png_every <= 1u || + current_frame % options.png_every == 0u)) { char path[1024]; std::snprintf(path, sizeof path, "%s/frame_%05u.png", options.png_dir, current_frame); From f220db1ee5436e241f4e432dbfcaddb2e6e980e5 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 10:57:18 +0200 Subject: [PATCH 3/3] Stop zero-filled shadow RAM from impersonating oracle evidence Mid-frame DFF captures do not carry a full MEM1 snapshot, so Mode-A replay must distinguish captured zero bytes from allocator zero-fill. Track validated MEM_UPDATE coverage at draw-command boundaries and expose texture hashes only when the complete source span is authoritative. Constraint: Rendering and replay digests must remain unchanged; only parity evidence authority is gated. Rejected: Treat all-zero hashes as unknown | genuine captured textures can legally contain only zero bytes. Confidence: high Scope-risk: narrow Directive: Preserve command-boundary provenance because sink draw callbacks are intentionally delayed. Tested: headless full build; frontend ctest 7/7; Aurora dolgx_replay build; null-null-hash subprocess regression. Not-tested: live GPU pixel replay of a partial-window DFF. --- graphics/frontend/CMakeLists.txt | 5 + .../dolruntime/aurora_recomp/replay.hpp | 9 +- graphics/frontend/src/replay.cpp | 13 ++- .../frontend/tests/replay_digest_test.cpp | 78 +++++++++++++- graphics/frontend/tools/dolgx_replay.cpp | 102 +++++++++++++++++- 5 files changed, 201 insertions(+), 6 deletions(-) diff --git a/graphics/frontend/CMakeLists.txt b/graphics/frontend/CMakeLists.txt index aaff594..747fa88 100644 --- a/graphics/frontend/CMakeLists.txt +++ b/graphics/frontend/CMakeLists.txt @@ -106,6 +106,11 @@ if(BUILD_TESTING) PRIVATE DolRuntime::replay_core ) + target_compile_definitions(replay_digest_tests + PRIVATE + DOLGX_REPLAY_TEST_PATH="$" + ) + add_dependencies(replay_digest_tests dolgx_replay) add_test(NAME replay_digest_tests COMMAND replay_digest_tests) add_executable(dff2dolt_tests diff --git a/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp b/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp index 25053a6..54d1992 100644 --- a/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp +++ b/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp @@ -49,15 +49,20 @@ using ReplayDrawObserver = void (*)(std::uint32_t frame_index, const ConsumedDraw &draw, unsigned long long cumulative_draw, void *user); +using ReplayMemUpdateObserver = void (*)(std::uint32_t guest_address, + std::uint32_t size, void *user); // Replays every record of a freshly opened (or rewound) reader. The optional -// observer taps every frontend decode event (histograms). +// observers tap frontend decode events, completed draws, and validated MEM1 +// updates in trace order. ReplayResult replay_trace(trace::TraceReader &reader, RetailGxFrontend::TraceEventObserver event_observer = nullptr, void *event_observer_user = nullptr, ReplayDrawObserver draw_observer = nullptr, - void *draw_observer_user = nullptr); + void *draw_observer_user = nullptr, + ReplayMemUpdateObserver mem_update_observer = nullptr, + void *mem_update_observer_user = nullptr); // "frame N draws D zdraws Z verts V topo T store S elems E fnv X state H" std::string format_digest_line(const FrameDigest &f); diff --git a/graphics/frontend/src/replay.cpp b/graphics/frontend/src/replay.cpp index 76ac5b6..8a31f64 100644 --- a/graphics/frontend/src/replay.cpp +++ b/graphics/frontend/src/replay.cpp @@ -40,6 +40,8 @@ struct ReplayContext { std::uint32_t frame_draw = 0; ReplayDrawObserver draw_observer = nullptr; void *draw_observer_user = nullptr; + ReplayMemUpdateObserver mem_update_observer = nullptr; + void *mem_update_observer_user = nullptr; }; bool mem1_resolver(void *user, u32 address, u32 size, @@ -129,7 +131,9 @@ ReplayResult replay_trace(trace::TraceReader &reader, RetailGxFrontend::TraceEventObserver event_observer, void *event_observer_user, ReplayDrawObserver draw_observer, - void *draw_observer_user) { + void *draw_observer_user, + ReplayMemUpdateObserver mem_update_observer, + void *mem_update_observer_user) { ReplayResult result; auto ctx = std::make_unique(); const std::uint32_t mem1_size = @@ -137,6 +141,8 @@ ReplayResult replay_trace(trace::TraceReader &reader, ctx->mem1.assign(mem1_size, 0u); ctx->draw_observer = draw_observer; ctx->draw_observer_user = draw_observer_user; + ctx->mem_update_observer = mem_update_observer; + ctx->mem_update_observer_user = mem_update_observer_user; DolGuestAddressResolver resolver; dol_guest_address_resolver_init_callback(&resolver, mem1_resolver, ctx.get()); @@ -288,6 +294,11 @@ ReplayResult replay_trace(trace::TraceReader &reader, break; } std::memcpy(ctx->mem1.data() + physical, bytes.data(), bytes.size()); + if (ctx->mem_update_observer != nullptr) { + ctx->mem_update_observer(guest_addr, + static_cast(bytes.size()), + ctx->mem_update_observer_user); + } break; } case trace::RecordKind::TmemSnapshot: { diff --git a/graphics/frontend/tests/replay_digest_test.cpp b/graphics/frontend/tests/replay_digest_test.cpp index 8304bd5..af6d259 100644 --- a/graphics/frontend/tests/replay_digest_test.cpp +++ b/graphics/frontend/tests/replay_digest_test.cpp @@ -12,8 +12,10 @@ #include #include +#include #include #include +#include #include #include @@ -102,7 +104,7 @@ constexpr std::uint32_t kTlutBase = 0x1000u; constexpr std::uint32_t kCopyBase = 0x1200u; constexpr std::uint32_t kMem1Size = 0x2000u; -std::vector build_display_list() { +std::vector build_display_list(bool enable_texture = false) { std::vector dl; push_bp(dl, DOL_GX_BP_REG_GENMODE, 3u << 14u); push_bp(dl, DOL_GX_BP_REG_LOAD_TLUT0, kTlutBase >> 5u); @@ -110,6 +112,8 @@ std::vector build_display_list() { push_bp(dl, DOL_GX_BP_REG_TX_SETTLUT + 1u, 0x20u | (1u << 10u)); push_bp(dl, DOL_GX_BP_REG_TX_SETIMAGE0 + 1u, tex_image0(16u, 8u, 1u)); push_bp(dl, DOL_GX_BP_REG_TX_SETIMAGE3 + 1u, kTextureBase >> 5u); + if (enable_texture) + push_bp(dl, 0x28u, (1u << 6u) | 1u); push_bp(dl, DOL_GX_BP_REG_EFB_TL, 0u); push_bp(dl, DOL_GX_BP_REG_EFB_WH, (7u << 10u) | 7u); push_bp(dl, DOL_GX_BP_REG_EFB_ADDR, kCopyBase >> 5u); @@ -303,16 +307,88 @@ void test_against_stats_gate_fails_on_sustained_mismatch(const char* path) { assert(!cmp.detail.empty()); } +void write_texture_provenance_trace(const char* path) { + const std::vector dl = build_display_list(true); + const std::vector fifo = + build_fifo(static_cast(dl.size())); + + trace::TraceWriter writer; + trace::TraceHeader header{}; + std::memcpy(header.game_id, "TEXPROV0", 8u); + header.mem1_size = kMem1Size; + assert(writer.open(path, header)); + + writer.frame_begin(1u); + write_region(writer, kArrayBase, 0x10u); + write_region(writer, kXfArrayBase, 0x80u); + write_region(writer, kTlutBase, 0x20u); + write_region(writer, kCopyBase, 0x60u); + writer.mem_update(kDlOffset, dl.data(), static_cast(dl.size())); + writer.set_array(0u, kArrayBase, 12u); + record_gx_writes(writer, fifo); + // The sink reports a completed draw when the following draw arrives. Put + // the explicit zero update between them so the first draw must retain the + // unresolved provenance it had at its own command boundary. + std::uint8_t zero_texture_half[64]{}; + writer.mem_update(kTextureBase, zero_texture_half, + sizeof zero_texture_half); + writer.call_display_list(0u, dl.data(), static_cast(dl.size())); + writer.mem_update(kTextureBase + sizeof zero_texture_half, + zero_texture_half, sizeof zero_texture_half); + writer.call_display_list(0u, dl.data(), static_cast(dl.size())); + writer.present_stats(fixture_stats(1u)); + + assert(writer.close()); + assert(writer.ok()); +} + +std::string shell_quote(const std::string& value) { + std::string quoted = "'"; + for (const char c : value) + quoted += c == '\'' ? "'\\''" : std::string(1u, c); + return quoted + "'"; +} + +void test_texture_hash_requires_captured_mem1(const char* trace_path, + const char* parity_path) { + write_texture_provenance_trace(trace_path); + const std::string command = + shell_quote(DOLGX_REPLAY_TEST_PATH) + " " + shell_quote(trace_path) + + " --quiet --write-parity-jsonl " + shell_quote(parity_path); + assert(std::system(command.c_str()) == 0); + + std::ifstream parity(parity_path); + assert(parity); + std::vector draws; + std::string line; + while (std::getline(parity, line)) { + if (line.find("\"record\":\"draw\"") != std::string::npos) + draws.push_back(line); + } + assert(draws.size() == 3u); + assert(draws[0].find("\"source_hash\":null") != std::string::npos); + assert(draws[0].find("\"source_hash_raw\":null") != std::string::npos); + assert(draws[1].find("\"source_hash\":null") != std::string::npos); + assert(draws[1].find("\"source_hash_raw\":null") != std::string::npos); + assert(draws[2].find("\"source_hash\":\"0x") != std::string::npos); + assert(draws[2].find("\"source_hash_raw\":\"0x") != std::string::npos); +} + } // namespace int main() { const char* fixture_path = "replay_digest_fixture.dolt"; const char* negative_path = "replay_digest_negative.dolt"; + const char* texture_path = "replay_texture_provenance.dolt"; + const char* parity_path = "replay_texture_provenance.jsonl"; write_fixture_trace(fixture_path); test_fixture_digest(fixture_path); test_against_stats_gate_fails_on_sustained_mismatch(negative_path); + test_texture_hash_requires_captured_mem1(texture_path, parity_path); std::remove(fixture_path); std::remove(negative_path); + std::remove(texture_path); + std::remove(parity_path); std::printf("replay_digest_tests passed\n"); return 0; } diff --git a/graphics/frontend/tools/dolgx_replay.cpp b/graphics/frontend/tools/dolgx_replay.cpp index 9131877..8387f3f 100644 --- a/graphics/frontend/tools/dolgx_replay.cpp +++ b/graphics/frontend/tools/dolgx_replay.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -178,10 +179,34 @@ struct ParityWriter { std::vector> clip_samples; }; + struct TextureSourceState { + bool valid = false; + std::uint32_t address = 0; + std::uint32_t size = 0; + std::uint32_t tlut_address = 0; + std::uint32_t tlut_size = 0; + }; + + struct DrawSourceAuthority { + std::array + texture{}; + std::array + tlut{}; + }; + std::ofstream out; DrawState state; std::deque pending_draw_states; + std::array + texture_sources{}; + std::deque pending_draw_sources; std::vector frames; + // A mid-frame DFF starts with a zero-filled replay buffer, not an + // authoritative MEM1 snapshot. Keep the byte ranges explicitly supplied by + // MEM_UPDATE separate from their current values so a real all-zero texture + // remains distinguishable from never-captured memory. + std::map initialized_mem1; ParityWriter(const char *path, const char *trace_path, const char *game_id) : out(path, std::ios::trunc) { @@ -207,12 +232,67 @@ struct ParityWriter { event.b < state.vat[event.a].size()) { state.vat[event.a][event.b] = event.c; state.vat_valid[event.a][event.b] = true; + } else if (event.kind == DOL_GX_RECOMP_EVENT_TEXTURE && + event.a < texture_sources.size()) { + texture_sources[event.a] = { + .valid = true, + .address = event.b, + .size = event.c, + .tlut_address = event.tlut_address, + .tlut_size = event.tlut_entries * 2u, + }; } else if (event.kind == DOL_GX_RECOMP_EVENT_DRAW) { // ConsumingAuroraRenderSink completes a draw when the following draw // arrives. Queue the command-boundary state so the delayed draw callback // cannot accidentally observe BP/CP writes belonging to its successor. pending_draw_states.push_back(state); + DrawSourceAuthority sources; + for (std::size_t slot = 0; slot < texture_sources.size(); ++slot) { + const TextureSourceState &source = texture_sources[slot]; + if (!source.valid) + continue; + sources.texture[slot] = has_mem1_bytes(source.address, source.size); + sources.tlut[slot] = + source.tlut_address >= + dolruntime::aurora_recomp::kTmemSnapshotAddressBase || + has_mem1_bytes(source.tlut_address, source.tlut_size); + } + pending_draw_sources.push_back(sources); + } + } + + void observe_mem_update(std::uint32_t guest_address, std::uint32_t size) { + if (size == 0u) + return; + std::uint32_t begin = dol_gx_recomp_guest_to_physical(guest_address); + std::uint32_t end = begin + size; + auto next = initialized_mem1.lower_bound(begin); + if (next != initialized_mem1.begin()) { + auto previous = std::prev(next); + if (previous->second >= begin) { + begin = previous->first; + end = std::max(end, previous->second); + next = initialized_mem1.erase(previous); + } + } + while (next != initialized_mem1.end() && next->first <= end) { + end = std::max(end, next->second); + next = initialized_mem1.erase(next); } + initialized_mem1.emplace(begin, end); + } + + bool has_mem1_bytes(std::uint32_t guest_address, std::uint32_t size) const { + if (size == 0u) + return false; + const std::uint32_t begin = + dol_gx_recomp_guest_to_physical(guest_address); + const std::uint32_t end = begin + size; + auto range = initialized_mem1.upper_bound(begin); + if (range == initialized_mem1.begin()) + return false; + --range; + return range->first <= begin && range->second >= end; } static void nullable_u32(std::ostream &stream, const DrawState &draw_state, @@ -587,6 +667,11 @@ struct ParityWriter { pending_draw_states.empty() ? state : pending_draw_states.front(); if (!pending_draw_states.empty()) pending_draw_states.pop_front(); + DrawSourceAuthority draw_sources; + if (!pending_draw_sources.empty()) { + draw_sources = pending_draw_sources.front(); + pending_draw_sources.pop_front(); + } if (frames.empty() || frames.back().frame != frame) { frames.push_back({.frame = frame, .first_draw = cumulative_draw, @@ -610,13 +695,20 @@ struct ParityWriter { const GeometryObservations geometry = observe_geometry(draw_state, draw); std::string texture_hash; std::string tlut_hash; - if (texture_enabled && texture.resolved && texture.host_data != nullptr && + const bool texture_source_captured = + texture_slot < draw_sources.texture.size() && + draw_sources.texture[texture_slot]; + if (texture_enabled && texture_source_captured && texture.resolved && + texture.host_data != nullptr && texture.size <= texture.host_available) { texture_hash = hex64(parity_fnv(texture.host_data, texture.size)); } const std::size_t tlut_size = static_cast(texture.tlut_entries) * 2u; + const bool tlut_source_captured = texture_slot < draw_sources.tlut.size() && + draw_sources.tlut[texture_slot]; if (texture_enabled && texture.has_tlut && + tlut_source_captured && texture.tlut_host_data != nullptr && tlut_size <= texture.tlut_host_available) { tlut_hash = hex64(parity_fnv(texture.tlut_host_data, tlut_size)); @@ -821,6 +913,11 @@ void cli_draw_observer(std::uint32_t frame_index, std::uint32_t frame_draw, cumulative_draw); } +void cli_mem_update_observer(std::uint32_t guest_address, std::uint32_t size, + void *user) { + static_cast(user)->observe_mem_update(guest_address, size); +} + void histogram_observe(const DolGxRecompTraceEvent &event, void *user) { auto *h = static_cast(user); switch (event.kind) { @@ -1093,7 +1190,8 @@ int main(int argc, char **argv) { }; const replay::ReplayResult result = replay::replay_trace( reader, (histogram || parity) ? cli_event_observer : nullptr, &observers, - parity ? cli_draw_observer : nullptr, parity.get()); + parity ? cli_draw_observer : nullptr, parity.get(), + parity ? cli_mem_update_observer : nullptr, parity.get()); if (result.truncated) std::fprintf(stderr, "dolgx_replay: trace ends mid-record (interrupted " "recording); replayed the complete prefix\n");