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/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..54d1992 100644 --- a/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp +++ b/graphics/frontend/include/dolruntime/aurora_recomp/replay.hpp @@ -44,15 +44,28 @@ 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); +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). -ReplayResult replay_trace( - trace::TraceReader& reader, - RetailGxFrontend::TraceEventObserver event_observer = nullptr, - void* event_observer_user = nullptr); +// 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, + 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); +std::string format_digest_line(const FrameDigest &f); struct StatsCompareResult { bool ok = false; @@ -69,6 +82,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..8a31f64 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,40 @@ 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; + ReplayMemUpdateObserver mem_update_observer = nullptr; + void *mem_update_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 +81,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 +106,46 @@ 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, + ReplayMemUpdateObserver mem_update_observer, + void *mem_update_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; + 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()); + 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 +166,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 +179,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 +222,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 +242,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)); @@ -260,6 +294,26 @@ 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: { + 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: { @@ -285,7 +339,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 +351,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 +362,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 +372,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/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/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..8387f3f 100644 --- a/graphics/frontend/tools/dolgx_replay.cpp +++ b/graphics/frontend/tools/dolgx_replay.cpp @@ -9,26 +9,37 @@ #include "dolgx_replay_pixels.hpp" +#include +#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 +85,841 @@ 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::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; + }; + + 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) { + 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_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, + 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); + } + + // 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) { + 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]; + 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 + + 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 (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; + 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]); + } + } + 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), + }; + const std::array world = { + world_f[0], world_f[1], world_f[2]}; + std::array clip_f{}; + if (draw.projection_type == 0u) { + 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 { + 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); + } + } + 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; + } + + 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(); + 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, + .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; + std::string tlut_hash; + 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)); + } + 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 << ",\"pe_control\":"; + nullable_u32(out, draw_state, 0x43u); + 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)) + << ",\"source_hash_raw\":" + << (texture_hash.empty() ? "null" : json_string(texture_hash)) + << ",\"tlut\":" + << (texture.has_tlut + ? std::to_string(texture.tlut_address & 0x03FFFFFFu) + : "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 & + dolruntime::aurora_recomp::kDrawTransformProjectionValid) != 0u) { + out << draw.projection_type << ",\"projection_coefficients\":"; + float_array(out, draw.projection, 6); + } else { + out << "null,\"projection_coefficients\":null"; + } + // 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)) != + 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_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_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\""; + } + 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 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) { case DOL_GX_RECOMP_EVENT_BP_REG: { const std::uint32_t reg = event.a; @@ -136,33 +980,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 +1034,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 +1086,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 +1099,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 +1125,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 +1167,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 +1175,34 @@ 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(), + 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"); + 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 +1211,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 +1242,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 +1285,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; 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);