Compare commits

..

39 Commits

Author SHA1 Message Date
Berk 7c9740fee8 [GUI] Add grid settings to GUI and implement grid snapping (#729) 2026-07-31 13:27:43 +03:00
Emil 544f588cfd Required imports for Astro Bot (#710)
* Required imports for Astro Bot

* Add missing clone to match ValueIndexCString
2026-07-31 12:31:14 +03:00
kuba ecd657006a AGC: retain label producers and report failed release writes (#719)
* AGC: retain active label producer records

Ported from the archived silent-hill-minimal branch (274ccfdf).

RegisterLabelProducer bounded its history with RemoveRange(0, 1024) once it
reached 4096 entries, which evicts the oldest records regardless of whether
their label write has completed. Those records are not a diagnostic cache:
a suspended DCB resolves its wait_reg_mem by finding the producer that will
write the watched label. Dropping an active record hides an earlier
same-submission label write, so a legitimate in-stream fence suspends
forever and the graphics queue stops.

Compact only completed history, and let the list exceed the soft bound when
every record is still active — correctness over a diagnostic limit.

Two hardening changes over the ported version, because a suspended queue is
exactly when every record is active:
- compaction is a single order-preserving pass instead of repeated RemoveAt,
  which would shift the tail per eviction (quadratic) while the label gate is
  held;
- when a pass frees nothing the bound is raised to twice the current count, so
  registration does not rescan the whole list on every subsequent add, and it
  is reset once compaction can make progress again.

Tests: 664 pass, 0 fail (SharpEmu.Libs.Tests 598 -> 600).

* AGC: keep produced label values a suspended wait still needs

GpuWaitRegistry.RecordProduced dropped the entire produced-value table once it
reached 8192 entries:

    if (_lastProduced.Count >= 8192) { _lastProduced.Clear(); }

Those entries are release state, not a cache. CollectDeadlockBroken is the only
way out for a DCB suspended on a WAIT_REG_MEM whose label the guest has since
recycled for other data: it replays the value a real producer already wrote to
that label. Clearing the table wholesale erases exactly the records live waiters
depend on, so every such waiter is stranded permanently — the suspended graphics
queue never resumes, and with it the render thread and the whole engine thread
graph park.

That is what Silent Hill (PPSA10112) hits. Its final unsatisfied waits watch
labels holding guest allocator metadata (each reads a pointer into its own
region rather than the expected 1), i.e. recycled memory, which is precisely
the case the deadlock breaker exists to handle — yet agc.deadlock_break fires 0
times across a run with 490 wait suspensions and 1068 successful release_mem
writes, because the producing values had already been cleared.

Prune only values no registered waiter is watching, and let the table exceed the
soft bound while they are all watched. Same reasoning as the label-producer
history in the preceding commit: correctness of synchronization state takes
precedence over a diagnostic bound.

Tests: 666 pass, 0 fail. The regression test fails against the previous
wholesale Clear() and passes with the prune, verified by reverting the fix.

* AGC: report release-label writes that never reach guest memory

Both release_mem handlers recorded the produced label value only on success:

    if (wroteData && dataSelection is 1 or 2) GpuWaitRegistry.RecordProduced(...)

with no else. A failed write is not benign here. That packet is the producer a
suspended WAIT_REG_MEM is waiting for, so when it fails the label is neither
written nor recorded, CollectDeadlockBroken has no value to replay, and the
graphics queue stays suspended forever — the render thread and the engine
thread graph park behind it with nothing in the log to say why.

Measured on Silent Hill (PPSA10112): the fatal AccessViolation lands exactly
here, on the presenter thread, in ApplySubmittedReleaseMem's label write into a
protected guest page. The faulting variant kills the process; the
returns-false variant wedges the GPU silently. Only the first was ever visible.

Report the failure (rate-limited: first 16, then powers of two) instead of
dropping it. This does not fix the underlying write failure — it makes a
permanently suspended queue diagnosable rather than silent.

Tests: 666 pass, 0 fail.
2026-07-31 12:14:38 +03:00
Daniel Freak a7ec3d5a77 [GUI] Add inline per-game settings and unify options styling (#728)
* [GUI] Add inline per-game settings and unify options styling

* [GUI] Update options styling & add scrollable area

* [GUI] Remove focus and pointover styles for options
2026-07-31 12:14:21 +03:00
kuba 97bd8c422e Combined: Pad, AGC, Metal, Vulkan, Ampr, overlay, and CPU hot-path fixes (#727)
* VideoOut: track guest-flip FPS and label load/stall gaps

Headline FPS follows VideoOut submit cadence rather than host presents, and long gaps show LOAD/STALL instead of a stale multi-second MS average.

* Metal: skip waitUntilCompleted when the command buffer is already Completed

Tiny write-back batches often finish before the wait; checking status avoids redundant ordered-queue round-trips.

* Metal: opt-in long-edge drawable cap via SHARPEMU_METAL_CAP_DRAWABLE

Default presentation resolution is unchanged; set SHARPEMU_METAL_CAP_DRAWABLE=1 to cap the drawable long edge at 1920.

* Pad: implement scePadGetTriggerEffectState under its own NID

NID znaWI0gpuo8 was mapped to sceUserServiceGetUserName as a "title-captured
alias". It is not that symbol. Recomputing the NID of every catalogued name
(base64 of the reversed first eight sha1 bytes of name+salt) resolves
znaWI0gpuo8 to scePadGetTriggerEffectState, and sceUserServiceGetUserName
hashes to 1xxcMiGu2fo instead. Auditing all 1087 export declarations the same
way found this to be the only NID whose declared name is wrong.

The consequence was not a missing export but a wrong one: the user-service
handler rejected the pad's arguments and returned
SCE_USER_SERVICE_ERROR_INVALID_PARAMETER about eighteen thousand times per
run in PPSA10112, so every poll fell back to a cached button bitmask. It
also hid the calls from every search for pad activity, which is why this
title was believed never to touch scePad at all.

The state size is taken from the caller's own frame rather than assumed: the
guest points the out-param at rbp-0x30 and stores its stack cookie at
rbp-0x28, leaving eight bytes for the state. Writing the sixteen the frame
superficially suggests would land on the cookie and fail the guest's stack
check - the same failure this codebase has already hit three times from
oversized HLE writes - so the test pins the size by asserting the cookie
survives.

No host pad exposes DualSense adaptive-trigger feedback, so the neutral
all-zero state is reported as success, which lets the caller take its normal
path instead of the fallback.

* Apply SDWA ABS/NEG as float sign-bit, not integer, modifiers

SDWA's ABS and NEG source modifiers are floating-point sign-bit
operations on GCN: ABS clears the sign bit, NEG flips it. We applied
them as integer operations instead - SAbs, and a two's-complement
negate of the raw bit pattern. That turns 1.0 into -4.0 and -3.0 into
1.5.

UE4 compiles the final line of DrawRectangle, OutPosition.xy *=
float2(1,-1), into a single V_MOV_B32 with SDWA NEG, so every UE
fullscreen pass had its clip-space Y silently skewed. The canonical
fullscreen triangle (1,-1) (-3,-1) (1,3) became (1,-4) (-3,-4) (1,1.5),
which covers 6/11 of the viewport instead of all of it.

That reproduces the measured defect exactly, on four independent
quantities: hypotenuse slope 8/11, crossings of y=+1 and y=-1 at
x=+7/11 and x=-9/11, and covered area 6/11 = 54.55% of the 2304x1296
viewport. It also explains why the edge was resolution-independent and
identical across six unrelated shaders - it is the same instruction in
every one of them.

The distinguishing evidence is the transform's fixed point. A wrong
scale would hold NDC -1 in x and +1 in y; the observed transform holds
the opposite corner in both. Independently, and using only the measured
line rather than any assumed vertex position: a wrong multiplier alone
leaves a residual of -18 whatever the multiplier, and a wrong addend
alone forces slope 1, not 8/11. Both terms had to be wrong at once,
which only a Y-only sign-bit corruption produces.

Float instructions are unaffected: GetFloatSource passes
applySdwaIntegerModifiers: false and applies its own modifiers, so this
path only ever fed raw-source reads - where the hardware behaviour is
the sign-bit one regardless of the opcode being a bit-move.

The sign bit is selected by the SDWA source-select width so a 16-bit
select flips bit 15 rather than bit 31.

Verified: emitted SPIR-V for the same shader changes from
OpISub %uint %uint_0 %2147 to OpBitwiseXor %uint %2147 %uint_2147483648;
25-program synthetic conformance gate passes; 805 tests green; three
100-110s live runs with no crashes and no new shader failures. The
end-to-end pixel re-measurement is NOT yet closed - see task #26.

* gpu: size single-channel 16-bit and two-channel 8-bit formats

GetFormatCompatibilityClass listed only R16Sfloat in the 16-bit class, so
GetVulkanImageByteCount computed zero bytes for R16Unorm, R16SNorm, R16Uint,
R16Sint and the R8G8 family. UploadGuestImageInitialData treats a zero
expected size as an incompatible upload and drops it, which leaves the
texture blank for the rest of the run rather than failing loudly.

Silent Hill uploads R16Unorm at 144x81, 240x135, 256x256, 512x512 and
1024x1024, and every one was rejected: the guest supplied exactly
width*height*2 bytes each time (23328 for 144x81) against an expected zero.

Also adds R8SNorm to the 8-bit class, which was missing for the same reason.

* Vulkan: enable textureCompressionBC when the device supports it

Guest BC1–BC7 textures can be sampled directly when the feature is available; warn when it is not.

* Share one guest image across sRGB/UNORM aliases

Ported from origin/fix/view-compatible-guest-images 7fb8fdf.

Rendering as sRGB and ImageLoad/Store-ing as UNORM at the same guest
address are the same surface accessed through different number formats.
Recreating the guest image per number format ping-pongs content between
two VkImages and loses the rendered pixels on every transition; the
mutable-format image now accepts the counterpart identity and serves it
through alias views. The commit names AvPlayer movie copies as the
pattern that needs this.

Adapted for this base: GetOrCreateGuestImage has since grown resolution
scaling and 3D/array support, so the alias accept is folded into the
current predicate (LogicalWidth/LogicalHeight/LogicalDepth/Type) rather
than the old Width/Height pair, and the storage-counterpart widening is
placed before the physical-dimension computation. The helper functions it
relies on (GetStorageImageFormat, IsCompatibleViewFormat) already existed.

* fix(gpu): one vertex attribute per guest stream view

The scalar evaluator gave every buffer_load_format instruction its own
attribute location. Two things multiply those: the CFG walk visits one
instruction on several paths, and an uber vertex shader fetches the same
stream from every material branch. UE's larger shaders reached 56 bindings
from 8 distinct views, and one reached 701 from 5.

Metal caps a vertex function at 31 attributes, so MoltenVK failed the MSL
compile with "'attribute' attribute parameter is out of bounds" and the
surrounding vkCreateGraphicsPipelines returned ErrorInitializationFailed.
Every draw using those pipelines was dropped, which is why Silent Hill:
The Short Message rendered a black scene. The vertex buffer count drove
Metal's buffer indices out of range too, giving the companion
"cannot reserve 'buffer' resource location at index 0" failures.

Key attributes by the guest stream view they read - absolute element
address, record stride and format - and alias every other fetch that
resolves to the same view onto that binding, so both translators map
those instruction PCs to one input variable. On PPSA10112 this takes the
worst shader from 56 attributes to 8 and pipeline failures from 840 to 0.

* AGC: deliver compute-queue completion events at the queue fence

sceAgcDriverSubmitAcb submissions never produced a completion interrupt.
NotifySubmittedDcbCompleted returned early for anything that was not the
graphics queue, so an ACB reaching its ordered-queue fence published
nothing. UE 4.27's dynamic-resolution GPU-timing heuristic parks the game
thread on exactly that interrupt, so the render side never advanced and
Silent Hill: The Short Message deadlocked after its first frame.

Give every queue a CompletionEventId: 0 for graphics (what it already
published) and the owner handle from sceAgcDriverSubmitAcb rdi for a
compute queue, which is the same value the guest passes to
sceAgcDriverAddEqEvent. Publish under that ident from the existing fence
point, which both PumpSubmittedQueue and ResumeSuspendedDcb already reach
only after the submission is fully parsed.

Delivery is synchronous on the ordered guest-action queue rather than on a
ThreadPool hop with a sleep. That action runs after the logical queue has
flushed and waited for its latest fence, which is the moment hardware
would raise end-of-pipe. Deferring past it can only make the interrupt
late and reorder it against registration changes.

Gating: the per-queue completion event is unconditional, because
completion interrupts do fire on real hardware and because delivery is
registration-gated -- TriggerRegisteredEvents only queues onto equeues
that registered this exact (ident, graphics filter) pair, and
sceAgcDriverAddEqEvent is the only producer of graphics registrations. A
title that never registers its ACB owner handle observes no change.
SHARPEMU_AGC_SUBMIT_COMPLETION_EVENT is left to gate only the broad
ident-ignoring fan-out (TriggerRegisteredEventsDistinct), which is a
compatibility guess rather than hardware behavior; it also stays scoped to
the graphics queue where it was measured, so enabling the flag does not
newly fan out across compute queues.

* fix(gpu): reflect guest CPU writes into large and render-target-aliased images

Ports the still-applicable half of the archived fork's stale-texture fix
(a42ccae) onto current main. Silent Hill: The Short Message (PPSA10112)
shows both faces: black title-screen UI (glyph atlas frozen at its first
upload) and stale/garbage rows on the brightness screen (a 3840x2160 UI
sheet whose backing bytes were never re-read).

1. Write tracking is armed under a byte budget, not a resolution cap.

   GetOrCreateGuestImage armed GuestImageWriteTracker only when
   target.Width <= 1920 && target.Height <= 1080. Silent Hill renders at
   3840x2160, so every one of its render targets was excluded from
   CPU-write tracking and no guest rewrite of one could ever invalidate
   it: SyncCpuWrittenGuestImages (the flip / ACQUIRE_MEM re-upload path)
   only ever visits ranges the tracker armed.

   The cap presumably existed as a perf guard, but resolution is the
   wrong proxy for the cost. Arming is one mprotect over the range, and
   the fault handler unprotects the whole range on the first store, so a
   write burst costs one fault regardless of size. What actually scales
   with the surface is the dirty re-upload: one byte[byteCount]
   allocation plus a full guest-memory read per dirty flip. So the guard
   is now a byte budget, set equal to the 128 MiB limit that
   SyncCpuWrittenGuestImages itself enforces before re-uploading. Above
   that, arming can only cost faults; it can never produce a re-upload.
   That is generous for 4K (RGBA8 32 MiB, RGBA16F 63 MiB, RGBA32F
   127 MiB all fit) while still excluding volume textures that a
   resolution cap could not see at all (512^3 RGBA8 is 512 MiB behind a
   "512x512" surface).

   Both sites now also arm the exact extent recorded in
   _guestImageExtents (GetTextureByteCount) instead of
   Width*Height*depth*GetTextureBytesPerPixel, so the armed range and
   the range the sync path reads back are the same bytes; the old
   expression over-counted block-compressed and unknown formats.

2. The CPU-texture refresh path no longer gates on IsCpuBacked alone.

   TryCreateCpuTextureRefreshResource bailed on !guestImage.IsCpuBacked.
   That flag is a latch: it flips false the first time an address is
   used as a render target and never flips back. A surface that was
   rendered into once and is afterwards rewritten by the guest CPU was
   therefore frozen at its last GPU content forever, even when the parse
   thread had already shipped fresh texels for it.

   The gate is replaced by ShouldRefreshGuestImageFromCpu: CPU-backed,
   or the parse-time write generation is above zero and differs from the
   generation recorded by the last upload. Keeping the positive-
   generation requirement preserves the pure GPU-feedback case (render
   into an image, then sample it) that IsCpuBacked used to protect: such
   a surface is now tracked (change 1) but never CPU-written, so its
   generation stays zero and its live image is left alone. This matters
   more than it did in the fork, precisely because change 1 arms
   tracking on far more render targets. Dropping the gate outright, as
   the fork did, would let a target sampled under a format tag that the
   availability map does not match be overwritten once with whatever
   sits in guest memory.

   The existing content fingerprint still suppresses redundant uploads,
   and MarkSampledImagesInitialized records the uploaded generation, so
   a rewritten surface re-uploads exactly once per guest write burst.

Not ported: the fork's third part added a PeekDirty guard to the
parse-time snapshot fast path in AgcExports. It is superseded. That path
now calls IsGuestImageUploadKnown, which already compares
_cpuBackedUploadGenerations against the tracker's write generation - a
monotonic value that survives another owner consuming the dirty flag,
unlike PeekDirty, which both EvictDirtyCachedTextures and
SyncCpuWrittenGuestImages clear. Guest images with no generation entry
are covered instead by SyncCpuWrittenGuestImages. Adding a non-consuming
PeekDirty there would also make every draw between a CPU write and the
next flip fall through to a full texel re-read of the surface (33 MiB
for a 4K sheet), since Track re-arms without clearing the dirty flag.
The fork's promotion-path Track ("vulkan.cpu-backed-image") is likewise
superseded: AgcExports already arms every sampled texture's backing
extent as "agc.decoded-texture" before reading its texels.

Both decisions are extracted as pure internal predicates so they can be
unit-tested; the Vulkan device code around them needs a real device.

* Kernel: correct equeue event delivery and waiter lifetime

Ports the event-queue rework from the archived silent-hill-minimal branch
(968e9606, 9e3abb12, 2be9cfbf, f157a115) onto current upstream. The equeue
implementation had not been touched upstream since that branch forked, so
none of it had landed.

Behaviour fixed:

- Per-waiter event reservation. A blocked waiter used to wake on "the queue
  has any pending event" (TryWake => HasPendingEvents) and then re-read the
  queue on resume, so a woken waiter could find the event already drained by
  another waiter and park again with the wake consumed. Events are now
  reserved to the waiter they are delivered to.
- Level-triggered events are preserved instead of being cleared by an
  unrelated read; only events that declare clear-on-read reset their trigger
  state.
- Queued interrupts are bound to the registration generation that produced
  them, so an event registered after a queue was reused cannot consume an
  interrupt raised for the previous registration.
- Deleting an equeue now terminates its waiters instead of leaving them
  blocked on a handle that no longer resolves.
- sceKernelTriggerUserEvent stores its third argument in the event's udata
  (0x18) rather than its data word (0x10). The guest reads it back with
  sceKernelGetEventUserData, which loads 0x18, so every triggered user event
  previously read back as 0. This matches the reference behaviour in shadPS4,
  where TriggerEvent takes udata and sceKernelGetEventUserData returns
  ev->udata. The upstream test asserting the data word is updated, since it
  encoded the inconsistency rather than the ABI.

KernelPthreadState gains TryGetCurrentThreadIdentity and a new
KernelSyncTraceFormatter carries the shared, opt-in diagnostic formatting the
ported code calls; both are gated behind the existing trace flag and do no
work when it is off.

Tests: 662 pass, 0 fail (SharpEmu.Libs.Tests 588 -> 598).

* Implement VOP3 0x149 V_BFE_I32 and 0x36A V_CVT_PKRTZ_F16_F32

PPSA10112 dropped three shaders per run on two unimplemented VOP3
opcodes. The translator fails a whole shader on an opcode it does not
know, so each one costs a dropped draw rather than wrong pixels.

0x149 sits between 0x148 V_BFE_U32 and 0x14A V_BFI_B32, and 0x36A
between 0x369 V_CVT_PKNORM_U16_F32 and 0x36D V_ADD3_U32; the
surrounding table entries already match the canonical map densely on
both sides of each gap. V_CVT_PKRTZ_F16_F32 needed no emitter - it was
already implemented for the VOP2 form at 0x2F and its VOP3 alias at
0x12F, and only the VOP3-only opcode number was missing.

V_BFE_I32 mirrors its unsigned sibling, masking offset and width to 5
bits, and differs only in extracting through a signed type so the field
sign-extends. GCN defines width 0 as returning 0 where SPIR-V leaves a
zero Count unspecified; VBfeU32 has the same gap, so this matches it
deliberately rather than diverging - fix both together if it matters.

Also widen ReportGuestPointerSplit, which filtered to an incoming value
of exactly 1. The crash being hunted leaves 0x0000007000000000, whose
low dword is 0, so the one detector built to catch this bug class could
never have reported it.

Verified: unsupported-opcode errors 3-4 per run -> 0, over a 130s run
that survives to the same stage. Metal translator still lacks VBfeI32;
Vulkan/MoltenVK is the macOS path so it is a divergence, not a blocker.

* AGC: retain active label producer records

Ported from the archived silent-hill-minimal branch (274ccfdf).

RegisterLabelProducer bounded its history with RemoveRange(0, 1024) once it
reached 4096 entries, which evicts the oldest records regardless of whether
their label write has completed. Those records are not a diagnostic cache:
a suspended DCB resolves its wait_reg_mem by finding the producer that will
write the watched label. Dropping an active record hides an earlier
same-submission label write, so a legitimate in-stream fence suspends
forever and the graphics queue stops.

Compact only completed history, and let the list exceed the soft bound when
every record is still active — correctness over a diagnostic limit.

Two hardening changes over the ported version, because a suspended queue is
exactly when every record is active:
- compaction is a single order-preserving pass instead of repeated RemoveAt,
  which would shift the tail per eviction (quadratic) while the label gate is
  held;
- when a pass frees nothing the bound is raised to twice the current count, so
  registration does not rescan the whole list on every subsequent add, and it
  is reset once compaction can make progress again.

Tests: 664 pass, 0 fail (SharpEmu.Libs.Tests 598 -> 600).

* AGC: keep produced label values a suspended wait still needs

GpuWaitRegistry.RecordProduced dropped the entire produced-value table once it
reached 8192 entries:

    if (_lastProduced.Count >= 8192) { _lastProduced.Clear(); }

Those entries are release state, not a cache. CollectDeadlockBroken is the only
way out for a DCB suspended on a WAIT_REG_MEM whose label the guest has since
recycled for other data: it replays the value a real producer already wrote to
that label. Clearing the table wholesale erases exactly the records live waiters
depend on, so every such waiter is stranded permanently — the suspended graphics
queue never resumes, and with it the render thread and the whole engine thread
graph park.

That is what Silent Hill (PPSA10112) hits. Its final unsatisfied waits watch
labels holding guest allocator metadata (each reads a pointer into its own
region rather than the expected 1), i.e. recycled memory, which is precisely
the case the deadlock breaker exists to handle — yet agc.deadlock_break fires 0
times across a run with 490 wait suspensions and 1068 successful release_mem
writes, because the producing values had already been cleared.

Prune only values no registered waiter is watching, and let the table exceed the
soft bound while they are all watched. Same reasoning as the label-producer
history in the preceding commit: correctness of synchronization state takes
precedence over a diagnostic bound.

Tests: 666 pass, 0 fail. The regression test fails against the previous
wholesale Clear() and passes with the prune, verified by reverting the fix.

* Correct VOP3 0x36A: V_CVT_PK_U16_U32, not V_CVT_PKRTZ_F16_F32

a2d186e mapped 0x36A to V_CVT_PKRTZ_F16_F32 on the strength of a gap in
our own opcode table. That was wrong, and wrong in the worst available
way: an emitter for V_CVT_PKRTZ_F16_F32 already existed, so instead of
failing loudly like an unknown opcode, the mapping would have emitted
float-pack semantics for an integer-pack instruction and produced
silently incorrect results.

LLVM is unambiguous:
    defm V_CVT_PK_U16_U32    : VOP3Only_Real_gfx10<0x36a>;
    defm V_CVT_PKRTZ_F16_F32 : VOP2_Real_gfx6_gfx7_gfx10<0x02f>;
so on Gen5 the float pack is VOP2 0x2F with VOP3 alias 0x12F - both of
which our table already had - and there is no VOP3-only encoding of it
to add. 0x36A sits with the other integer/normalised pack conversions
at 0x368/0x369/0x36B.

silent-hill-minimal-rebased had this right all along.

The V_BFE_I32 half of a2d186e stands: LLVM confirms 0x149, and that
branch maps it identically.

Lesson, since I had just warned someone else about exactly this: a gap
in a table is evidence about numbering, not about identity. Inference
from neighbouring entries is fine for narrowing candidates and worth
nothing as a conclusion - especially when a plausible emitter already
exists to swallow the mistake quietly.

* AGC: report release-label writes that never reach guest memory

Both release_mem handlers recorded the produced label value only on success:

    if (wroteData && dataSelection is 1 or 2) GpuWaitRegistry.RecordProduced(...)

with no else. A failed write is not benign here. That packet is the producer a
suspended WAIT_REG_MEM is waiting for, so when it fails the label is neither
written nor recorded, CollectDeadlockBroken has no value to replay, and the
graphics queue stays suspended forever — the render thread and the engine
thread graph park behind it with nothing in the log to say why.

Measured on Silent Hill (PPSA10112): the fatal AccessViolation lands exactly
here, on the presenter thread, in ApplySubmittedReleaseMem's label write into a
protected guest page. The faulting variant kills the process; the
returns-false variant wedges the GPU silently. Only the first was ever visible.

Report the failure (rate-limited: first 16, then powers of two) instead of
dropping it. This does not fix the underlying write failure — it makes a
permanently suspended queue diagnosable rather than silent.

Tests: 666 pass, 0 fail.

* CPU: gate periodic import logs and fast-path memcpy/memmove leaves

SHARPEMU_LOG_IMPORT_PERIODIC=1 re-enables periodic Import# tracing. memcpy/memmove use a thin TryCopy leaf that skips register marshalling.

* Ampr: cooked-id index preload and host FD LRU cache

Preload the app0 APR index during bind and bound the open host-file cache so streaming reads stay responsive under large title archives.

* Vulkan: abandon timed-out guest fences without blocking later frames

Move fence-timeout submissions out of the blocking queue, keep GPU objects alive until the fence signals, and retire them from an abandoned list so one hung dispatch cannot re-block capacity waits.

* AGC: follow command-buffer branches across arena switches

Implement sceAgcCbBranch and walk INDIRECT_BUFFER so submissions that continue in a linked buffer keep their flip and end-of-frame labels.
2026-07-31 12:14:02 +03:00
kuba c4ae4a2059 Vulkan: abandon timed-out guest fences without blocking later frames (#726)
Move fence-timeout submissions out of the blocking queue, keep GPU objects alive until the fence signals, and retire them from an abandoned list so one hung dispatch cannot re-block capacity waits.
2026-07-31 12:13:42 +03:00
kuba 93c9f14081 Ampr: cooked-id index preload and host FD LRU cache (#725)
Preload the app0 APR index during bind and bound the open host-file cache so streaming reads stay responsive under large title archives.
2026-07-31 12:13:33 +03:00
kuba 532251c0c3 CPU: gate periodic import logs and fast-path memcpy/memmove leaves (#724)
SHARPEMU_LOG_IMPORT_PERIODIC=1 re-enables periodic Import# tracing. memcpy/memmove use a thin TryCopy leaf that skips register marshalling.
2026-07-31 12:13:24 +03:00
kuba 816ec4ad27 Kernel: correct equeue event delivery and waiter lifetime (#723)
Ports the event-queue rework from the archived silent-hill-minimal branch
(968e9606, 9e3abb12, 2be9cfbf, f157a115) onto current upstream. The equeue
implementation had not been touched upstream since that branch forked, so
none of it had landed.

Behaviour fixed:

- Per-waiter event reservation. A blocked waiter used to wake on "the queue
  has any pending event" (TryWake => HasPendingEvents) and then re-read the
  queue on resume, so a woken waiter could find the event already drained by
  another waiter and park again with the wake consumed. Events are now
  reserved to the waiter they are delivered to.
- Level-triggered events are preserved instead of being cleared by an
  unrelated read; only events that declare clear-on-read reset their trigger
  state.
- Queued interrupts are bound to the registration generation that produced
  them, so an event registered after a queue was reused cannot consume an
  interrupt raised for the previous registration.
- Deleting an equeue now terminates its waiters instead of leaving them
  blocked on a handle that no longer resolves.
- sceKernelTriggerUserEvent stores its third argument in the event's udata
  (0x18) rather than its data word (0x10). The guest reads it back with
  sceKernelGetEventUserData, which loads 0x18, so every triggered user event
  previously read back as 0. This matches the reference behaviour in shadPS4,
  where TriggerEvent takes udata and sceKernelGetEventUserData returns
  ev->udata. The upstream test asserting the data word is updated, since it
  encoded the inconsistency rather than the ABI.

KernelPthreadState gains TryGetCurrentThreadIdentity and a new
KernelSyncTraceFormatter carries the shared, opt-in diagnostic formatting the
ported code calls; both are gated behind the existing trace flag and do no
work when it is off.

Tests: 662 pass, 0 fail (SharpEmu.Libs.Tests 588 -> 598).
2026-07-31 12:13:15 +03:00
kuba 82c2c7f48c fix(gpu): reflect guest CPU writes into large and render-target-aliased images (#722)
Ports the still-applicable half of the archived fork's stale-texture fix
(a42ccae) onto current main. Silent Hill: The Short Message (PPSA10112)
shows both faces: black title-screen UI (glyph atlas frozen at its first
upload) and stale/garbage rows on the brightness screen (a 3840x2160 UI
sheet whose backing bytes were never re-read).

1. Write tracking is armed under a byte budget, not a resolution cap.

   GetOrCreateGuestImage armed GuestImageWriteTracker only when
   target.Width <= 1920 && target.Height <= 1080. Silent Hill renders at
   3840x2160, so every one of its render targets was excluded from
   CPU-write tracking and no guest rewrite of one could ever invalidate
   it: SyncCpuWrittenGuestImages (the flip / ACQUIRE_MEM re-upload path)
   only ever visits ranges the tracker armed.

   The cap presumably existed as a perf guard, but resolution is the
   wrong proxy for the cost. Arming is one mprotect over the range, and
   the fault handler unprotects the whole range on the first store, so a
   write burst costs one fault regardless of size. What actually scales
   with the surface is the dirty re-upload: one byte[byteCount]
   allocation plus a full guest-memory read per dirty flip. So the guard
   is now a byte budget, set equal to the 128 MiB limit that
   SyncCpuWrittenGuestImages itself enforces before re-uploading. Above
   that, arming can only cost faults; it can never produce a re-upload.
   That is generous for 4K (RGBA8 32 MiB, RGBA16F 63 MiB, RGBA32F
   127 MiB all fit) while still excluding volume textures that a
   resolution cap could not see at all (512^3 RGBA8 is 512 MiB behind a
   "512x512" surface).

   Both sites now also arm the exact extent recorded in
   _guestImageExtents (GetTextureByteCount) instead of
   Width*Height*depth*GetTextureBytesPerPixel, so the armed range and
   the range the sync path reads back are the same bytes; the old
   expression over-counted block-compressed and unknown formats.

2. The CPU-texture refresh path no longer gates on IsCpuBacked alone.

   TryCreateCpuTextureRefreshResource bailed on !guestImage.IsCpuBacked.
   That flag is a latch: it flips false the first time an address is
   used as a render target and never flips back. A surface that was
   rendered into once and is afterwards rewritten by the guest CPU was
   therefore frozen at its last GPU content forever, even when the parse
   thread had already shipped fresh texels for it.

   The gate is replaced by ShouldRefreshGuestImageFromCpu: CPU-backed,
   or the parse-time write generation is above zero and differs from the
   generation recorded by the last upload. Keeping the positive-
   generation requirement preserves the pure GPU-feedback case (render
   into an image, then sample it) that IsCpuBacked used to protect: such
   a surface is now tracked (change 1) but never CPU-written, so its
   generation stays zero and its live image is left alone. This matters
   more than it did in the fork, precisely because change 1 arms
   tracking on far more render targets. Dropping the gate outright, as
   the fork did, would let a target sampled under a format tag that the
   availability map does not match be overwritten once with whatever
   sits in guest memory.

   The existing content fingerprint still suppresses redundant uploads,
   and MarkSampledImagesInitialized records the uploaded generation, so
   a rewritten surface re-uploads exactly once per guest write burst.

Not ported: the fork's third part added a PeekDirty guard to the
parse-time snapshot fast path in AgcExports. It is superseded. That path
now calls IsGuestImageUploadKnown, which already compares
_cpuBackedUploadGenerations against the tracker's write generation - a
monotonic value that survives another owner consuming the dirty flag,
unlike PeekDirty, which both EvictDirtyCachedTextures and
SyncCpuWrittenGuestImages clear. Guest images with no generation entry
are covered instead by SyncCpuWrittenGuestImages. Adding a non-consuming
PeekDirty there would also make every draw between a CPU write and the
next flip fall through to a full texel re-read of the surface (33 MiB
for a 4K sheet), since Track re-arms without clearing the dirty flag.
The fork's promotion-path Track ("vulkan.cpu-backed-image") is likewise
superseded: AgcExports already arms every sampled texture's backing
extent as "agc.decoded-texture" before reading its texels.

Both decisions are extracted as pure internal predicates so they can be
unit-tested; the Vulkan device code around them needs a real device.
2026-07-31 12:12:54 +03:00
kuba 531e35b6d5 AGC: deliver compute-queue completion events at the queue fence (#721)
sceAgcDriverSubmitAcb submissions never produced a completion interrupt.
NotifySubmittedDcbCompleted returned early for anything that was not the
graphics queue, so an ACB reaching its ordered-queue fence published
nothing. UE 4.27's dynamic-resolution GPU-timing heuristic parks the game
thread on exactly that interrupt, so the render side never advanced and
Silent Hill: The Short Message deadlocked after its first frame.

Give every queue a CompletionEventId: 0 for graphics (what it already
published) and the owner handle from sceAgcDriverSubmitAcb rdi for a
compute queue, which is the same value the guest passes to
sceAgcDriverAddEqEvent. Publish under that ident from the existing fence
point, which both PumpSubmittedQueue and ResumeSuspendedDcb already reach
only after the submission is fully parsed.

Delivery is synchronous on the ordered guest-action queue rather than on a
ThreadPool hop with a sleep. That action runs after the logical queue has
flushed and waited for its latest fence, which is the moment hardware
would raise end-of-pipe. Deferring past it can only make the interrupt
late and reorder it against registration changes.

Gating: the per-queue completion event is unconditional, because
completion interrupts do fire on real hardware and because delivery is
registration-gated -- TriggerRegisteredEvents only queues onto equeues
that registered this exact (ident, graphics filter) pair, and
sceAgcDriverAddEqEvent is the only producer of graphics registrations. A
title that never registers its ACB owner handle observes no change.
SHARPEMU_AGC_SUBMIT_COMPLETION_EVENT is left to gate only the broad
ident-ignoring fan-out (TriggerRegisteredEventsDistinct), which is a
compatibility guess rather than hardware behavior; it also stays scoped to
the graphics queue where it was measured, so enabling the flag does not
newly fan out across compute queues.
2026-07-31 12:12:43 +03:00
kuba 3f9bd2b92b AGC: follow command-buffer branches across arena switches (#720)
Implement sceAgcCbBranch and walk INDIRECT_BUFFER so submissions that continue in a linked buffer keep their flip and end-of-frame labels.
2026-07-31 12:12:35 +03:00
kuba eb0653eded fix(gpu): one vertex attribute per guest stream view (#718)
The scalar evaluator gave every buffer_load_format instruction its own
attribute location. Two things multiply those: the CFG walk visits one
instruction on several paths, and an uber vertex shader fetches the same
stream from every material branch. UE's larger shaders reached 56 bindings
from 8 distinct views, and one reached 701 from 5.

Metal caps a vertex function at 31 attributes, so MoltenVK failed the MSL
compile with "'attribute' attribute parameter is out of bounds" and the
surrounding vkCreateGraphicsPipelines returned ErrorInitializationFailed.
Every draw using those pipelines was dropped, which is why Silent Hill:
The Short Message rendered a black scene. The vertex buffer count drove
Metal's buffer indices out of range too, giving the companion
"cannot reserve 'buffer' resource location at index 0" failures.

Key attributes by the guest stream view they read - absolute element
address, record stride and format - and alias every other fetch that
resolves to the same view onto that binding, so both translators map
those instruction PCs to one input variable. On PPSA10112 this takes the
worst shader from 56 attributes to 8 and pipeline failures from 840 to 0.
2026-07-31 12:12:26 +03:00
kuba e1695cf87f Share one guest image across sRGB/UNORM aliases (#717)
Ported from origin/fix/view-compatible-guest-images 7fb8fdf.

Rendering as sRGB and ImageLoad/Store-ing as UNORM at the same guest
address are the same surface accessed through different number formats.
Recreating the guest image per number format ping-pongs content between
two VkImages and loses the rendered pixels on every transition; the
mutable-format image now accepts the counterpart identity and serves it
through alias views. The commit names AvPlayer movie copies as the
pattern that needs this.

Adapted for this base: GetOrCreateGuestImage has since grown resolution
scaling and 3D/array support, so the alias accept is folded into the
current predicate (LogicalWidth/LogicalHeight/LogicalDepth/Type) rather
than the old Width/Height pair, and the storage-counterpart widening is
placed before the physical-dimension computation. The helper functions it
relies on (GetStorageImageFormat, IsCompatibleViewFormat) already existed.
2026-07-31 12:12:09 +03:00
kuba 0dd543354d Vulkan: enable textureCompressionBC when the device supports it (#716)
Guest BC1–BC7 textures can be sampled directly when the feature is available; warn when it is not.
2026-07-31 12:11:53 +03:00
kuba fc5b6baaa7 gpu: size single-channel 16-bit and two-channel 8-bit formats (#715)
GetFormatCompatibilityClass listed only R16Sfloat in the 16-bit class, so
GetVulkanImageByteCount computed zero bytes for R16Unorm, R16SNorm, R16Uint,
R16Sint and the R8G8 family. UploadGuestImageInitialData treats a zero
expected size as an incompatible upload and drops it, which leaves the
texture blank for the rest of the run rather than failing loudly.

Silent Hill uploads R16Unorm at 144x81, 240x135, 256x256, 512x512 and
1024x1024, and every one was rejected: the guest supplied exactly
width*height*2 bytes each time (23328 for 144x81) against an expected zero.

Also adds R8SNorm to the 8-bit class, which was missing for the same reason.
2026-07-31 12:11:44 +03:00
kuba e5e02c0908 Shader: implement V_BFE_I32 and correct VOP3 0x36A mapping (#714)
* Implement VOP3 0x149 V_BFE_I32 and 0x36A V_CVT_PKRTZ_F16_F32

PPSA10112 dropped three shaders per run on two unimplemented VOP3
opcodes. The translator fails a whole shader on an opcode it does not
know, so each one costs a dropped draw rather than wrong pixels.

0x149 sits between 0x148 V_BFE_U32 and 0x14A V_BFI_B32, and 0x36A
between 0x369 V_CVT_PKNORM_U16_F32 and 0x36D V_ADD3_U32; the
surrounding table entries already match the canonical map densely on
both sides of each gap. V_CVT_PKRTZ_F16_F32 needed no emitter - it was
already implemented for the VOP2 form at 0x2F and its VOP3 alias at
0x12F, and only the VOP3-only opcode number was missing.

V_BFE_I32 mirrors its unsigned sibling, masking offset and width to 5
bits, and differs only in extracting through a signed type so the field
sign-extends. GCN defines width 0 as returning 0 where SPIR-V leaves a
zero Count unspecified; VBfeU32 has the same gap, so this matches it
deliberately rather than diverging - fix both together if it matters.

Also widen ReportGuestPointerSplit, which filtered to an incoming value
of exactly 1. The crash being hunted leaves 0x0000007000000000, whose
low dword is 0, so the one detector built to catch this bug class could
never have reported it.

Verified: unsupported-opcode errors 3-4 per run -> 0, over a 130s run
that survives to the same stage. Metal translator still lacks VBfeI32;
Vulkan/MoltenVK is the macOS path so it is a divergence, not a blocker.

* Correct VOP3 0x36A: V_CVT_PK_U16_U32, not V_CVT_PKRTZ_F16_F32

a2d186e mapped 0x36A to V_CVT_PKRTZ_F16_F32 on the strength of a gap in
our own opcode table. That was wrong, and wrong in the worst available
way: an emitter for V_CVT_PKRTZ_F16_F32 already existed, so instead of
failing loudly like an unknown opcode, the mapping would have emitted
float-pack semantics for an integer-pack instruction and produced
silently incorrect results.

LLVM is unambiguous:
    defm V_CVT_PK_U16_U32    : VOP3Only_Real_gfx10<0x36a>;
    defm V_CVT_PKRTZ_F16_F32 : VOP2_Real_gfx6_gfx7_gfx10<0x02f>;
so on Gen5 the float pack is VOP2 0x2F with VOP3 alias 0x12F - both of
which our table already had - and there is no VOP3-only encoding of it
to add. 0x36A sits with the other integer/normalised pack conversions
at 0x368/0x369/0x36B.

silent-hill-minimal-rebased had this right all along.

The V_BFE_I32 half of a2d186e stands: LLVM confirms 0x149, and that
branch maps it identically.

Lesson, since I had just warned someone else about exactly this: a gap
in a table is evidence about numbering, not about identity. Inference
from neighbouring entries is fine for narrowing candidates and worth
nothing as a conclusion - especially when a plausible emitter already
exists to swallow the mistake quietly.
2026-07-31 12:09:50 +03:00
kuba 539baa66e7 Apply SDWA ABS/NEG as float sign-bit, not integer, modifiers (#713)
SDWA's ABS and NEG source modifiers are floating-point sign-bit
operations on GCN: ABS clears the sign bit, NEG flips it. We applied
them as integer operations instead - SAbs, and a two's-complement
negate of the raw bit pattern. That turns 1.0 into -4.0 and -3.0 into
1.5.

UE4 compiles the final line of DrawRectangle, OutPosition.xy *=
float2(1,-1), into a single V_MOV_B32 with SDWA NEG, so every UE
fullscreen pass had its clip-space Y silently skewed. The canonical
fullscreen triangle (1,-1) (-3,-1) (1,3) became (1,-4) (-3,-4) (1,1.5),
which covers 6/11 of the viewport instead of all of it.

That reproduces the measured defect exactly, on four independent
quantities: hypotenuse slope 8/11, crossings of y=+1 and y=-1 at
x=+7/11 and x=-9/11, and covered area 6/11 = 54.55% of the 2304x1296
viewport. It also explains why the edge was resolution-independent and
identical across six unrelated shaders - it is the same instruction in
every one of them.

The distinguishing evidence is the transform's fixed point. A wrong
scale would hold NDC -1 in x and +1 in y; the observed transform holds
the opposite corner in both. Independently, and using only the measured
line rather than any assumed vertex position: a wrong multiplier alone
leaves a residual of -18 whatever the multiplier, and a wrong addend
alone forces slope 1, not 8/11. Both terms had to be wrong at once,
which only a Y-only sign-bit corruption produces.

Float instructions are unaffected: GetFloatSource passes
applySdwaIntegerModifiers: false and applies its own modifiers, so this
path only ever fed raw-source reads - where the hardware behaviour is
the sign-bit one regardless of the opcode being a bit-move.

The sign bit is selected by the SDWA source-select width so a 16-bit
select flips bit 15 rather than bit 31.

Verified: emitted SPIR-V for the same shader changes from
OpISub %uint %uint_0 %2147 to OpBitwiseXor %uint %2147 %uint_2147483648;
25-program synthetic conformance gate passes; 805 tests green; three
100-110s live runs with no crashes and no new shader failures. The
end-to-end pixel re-measurement is NOT yet closed - see task #26.
2026-07-31 12:09:41 +03:00
kuba b572738547 Pad: implement scePadGetTriggerEffectState under its own NID (#712)
NID znaWI0gpuo8 was mapped to sceUserServiceGetUserName as a "title-captured
alias". It is not that symbol. Recomputing the NID of every catalogued name
(base64 of the reversed first eight sha1 bytes of name+salt) resolves
znaWI0gpuo8 to scePadGetTriggerEffectState, and sceUserServiceGetUserName
hashes to 1xxcMiGu2fo instead. Auditing all 1087 export declarations the same
way found this to be the only NID whose declared name is wrong.

The consequence was not a missing export but a wrong one: the user-service
handler rejected the pad's arguments and returned
SCE_USER_SERVICE_ERROR_INVALID_PARAMETER about eighteen thousand times per
run in PPSA10112, so every poll fell back to a cached button bitmask. It
also hid the calls from every search for pad activity, which is why this
title was believed never to touch scePad at all.

The state size is taken from the caller's own frame rather than assumed: the
guest points the out-param at rbp-0x30 and stores its stack cookie at
rbp-0x28, leaving eight bytes for the state. Writing the sixteen the frame
superficially suggests would land on the cookie and fail the guest's stack
check - the same failure this codebase has already hit three times from
oversized HLE writes - so the test pins the size by asserting the cookie
survives.

No host pad exposes DualSense adaptive-trigger feedback, so the neutral
all-zero state is reported as success, which lets the caller take its normal
path instead of the fallback.
2026-07-31 12:09:24 +03:00
kuba b75e4e01a0 Metal: opt-in long-edge drawable cap via SHARPEMU_METAL_CAP_DRAWABLE (#711)
Default presentation resolution is unchanged; set SHARPEMU_METAL_CAP_DRAWABLE=1 to cap the drawable long edge at 1920.
2026-07-31 12:09:09 +03:00
kuba 79aa764d03 Metal: skip waitUntilCompleted when the command buffer is already Completed (#709)
Tiny write-back batches often finish before the wait; checking status avoids redundant ordered-queue round-trips.
2026-07-31 12:08:56 +03:00
kuba ec65419c0a VideoOut: track guest-flip FPS and label load/stall gaps (#708)
Headline FPS follows VideoOut submit cadence rather than host presents, and long gaps show LOAD/STALL instead of a stale multi-second MS average.
2026-07-31 12:08:46 +03:00
ParantezTech c990b7799f Merge branch 'main' of https://github.com/sharpemu/sharpemu 2026-07-31 01:47:13 +03:00
Berk e7149bf41f Shader cfg ssa (#707)
* [shader] add scalar control flow graph
* [shader] add gen5 branch resolver
* [shader] add scalar reaching definitions
* [shader] add cfg and dataflow tests
* [shader] guard descriptors from unresolved registers
2026-07-31 01:43:28 +03:00
Berk 444af50f4b Shader cfg ssa (#707)
* [shader] add scalar control flow graph

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [shader] add scalar control flow graph

* [shader] add gen5 branch resolver

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [shader] add gen5 branch resolver

* [shader] add scalar reaching definitions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [shader] add scalar reaching definitions

* [shader] add cfg and dataflow tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [shader] add cfg and dataflow tests

* [shader] guard descriptors from unresolved registers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [shader] guard descriptors from unresolved registers

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:39:56 +03:00
Berk 5864328e35 Merge media decoding into one FFmpeg bridge (#706)
* [media] merge bink into shared ffmpeg bridge

* [avplayer] decode in process and fix stream info size

* [font] add glyph and teardown exports

* [build] bump ffmpeg runtime to 3b502d4

* [font] add glyph and teardown exports

* [build] bump ffmpeg runtime to 3b502d4

* [avplayer] decode in process

* [font] add glyph and teardown exports

* [build] bump ffmpeg runtime to 3b502d4
2026-07-31 01:31:14 +03:00
Daniel Freak 753ddf93be [GUI] Update window chrome controls (#700) 2026-07-30 17:03:57 +03:00
Astell 5f1bd5a77c fix vulkan error from astrobot (#689)
* fix vulkan error from astrobot

* a bit of a clean

* a bit of a clean
2026-07-30 12:58:43 +03:00
Daniel Freak f08308daf2 [GUI] Redesign options page (#699) 2026-07-30 12:58:22 +03:00
Daniel Freak 77f22973cb [GUI] Update library page layout (#690)
* [GUI] Add horizontal game tiles

* [GUI] Update launching elements layout

* [GUI] Change cards format to squares
2026-07-30 02:47:15 +03:00
Berk 996de70f52 Game compat updates (#691)
* [agc] Add indirect draws

* [video] Match render target views

* [savedata] Allow dialog reinit

* [ime] Add default profile

* [GUI] update missing translations for profile box
2026-07-29 15:59:57 +03:00
Berk d5108e854d chore: bump version to 0.0.3-hotfix-2 (#687) 2026-07-29 01:50:26 +03:00
ParantezTech b020f1676a Merge branch 'main' of https://github.com/sharpemu/sharpemu 2026-07-29 01:48:28 +03:00
ParantezTech 02938b5d5b [GUI] removed not official Discord link from GUI 2026-07-29 01:47:35 +03:00
Berk 7b7a48a834 [CI] Commits are now automatically added to the release notes (#686) 2026-07-29 01:47:21 +03:00
Daniel Freak faf49f689d [GUI] Add live localization bindings and fill missing locales (#685) 2026-07-29 01:25:48 +03:00
Daniel Freak 882a6c06e0 [GUI] add custom top-bar across al platforms (#682) 2026-07-28 21:24:43 +03:00
ParantezTech b21dd9fb92 [GUI] reworked languages for missing translations and added new ones 2026-07-28 17:38:53 +03:00
Daniel Freak b07e4f2bc6 [GUI] add game library watcher & remove rescan button (#678) 2026-07-28 17:09:22 +03:00
120 changed files with 12981 additions and 2633 deletions
+61 -4
View File
@@ -231,6 +231,11 @@ jobs:
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
@@ -258,6 +263,61 @@ jobs:
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
- name: Build release notes
shell: bash
env:
MAX_COMMITS: 200
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
VERSION: ${{ needs.init.outputs.version }}
run: |
set -euo pipefail
previous_tag="$(git describe --tags --abbrev=0 --match 'v*' "${RELEASE_TAG}^" 2>/dev/null || true)"
if [ -n "${previous_tag}" ]; then
range="${previous_tag}..${RELEASE_TAG}"
else
range="${RELEASE_TAG}"
fi
git log --no-merges --reverse --pretty=tformat:"%H%x1f%h%x1f%s" "${range}" > commits.txt
total="$(wc -l < commits.txt)"
{
printf 'Automated SharpEmu v%s build for commit [`%s`](%s/commit/%s).\n\n' \
"${VERSION}" "${SHORT_SHA}" "${REPO_URL}" "${GITHUB_SHA}"
if [ -n "${previous_tag}" ]; then
printf '## Changes since %s\n\n' "${previous_tag}"
else
printf '## Changes\n\n'
fi
if [ "${total}" -eq 0 ]; then
printf '_No commits since %s._\n' "${previous_tag}"
else
head -n "${MAX_COMMITS}" commits.txt | while IFS=$'\x1f' read -r sha short subject; do
printf -- '- %s ([`%s`](%s/commit/%s))\n' "${subject}" "${short}" "${REPO_URL}" "${sha}"
done
if [ "${total}" -gt "${MAX_COMMITS}" ]; then
printf '\n_…and %s more commits._\n' "$((total - MAX_COMMITS))"
fi
fi
printf '\n'
if [ -n "${previous_tag}" ]; then
printf '**Full changelog**: %s/compare/%s...%s\n' "${REPO_URL}" "${previous_tag}" "${RELEASE_TAG}"
else
printf '**Full changelog**: %s/commits/%s\n' "${REPO_URL}" "${RELEASE_TAG}"
fi
} > release-notes.md
cat release-notes.md
- name: Create release
shell: bash
env:
@@ -265,7 +325,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_NAME: ${{ needs.init.outputs.release-name }}
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
VERSION: ${{ needs.init.outputs.version }}
run: |
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -ne 3 ]; then
@@ -273,8 +332,6 @@ jobs:
exit 1
fi
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
exit 1
@@ -283,4 +340,4 @@ jobs:
gh release create "${RELEASE_TAG}" "${assets[@]}" \
--verify-tag \
--title "${RELEASE_NAME}" \
--notes "${notes}"
--notes-file release-notes.md
+1 -1
View File
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.3-hotfix-1</SharpEmuVersion>
<SharpEmuVersion>0.0.3-hotfix-2</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+6
View File
@@ -19,6 +19,12 @@ precedence = "aggregate"
SPDX-FileCopyrightText = "SharpEmu Emulator Project"
SPDX-License-Identifier = "GPL-2.0-or-later"
[[annotations]]
path = "src/SharpEmu.GUI/Assets/Fonts/MaterialSymbolsRounded-*.ttf"
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Google LLC"
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = "src/SharpEmu.LibAtrac9/**"
precedence = "aggregate"
+58
View File
@@ -157,6 +157,62 @@ def ensure_tag_does_not_exist(
raise ReleaseError(f"Tag {tag} already exists on {remote}.")
def get_previous_tag(repository_root: Path) -> str:
try:
return run_git(
"describe",
"--tags",
"--abbrev=0",
"--match",
"v*",
"HEAD",
cwd=repository_root,
capture_output=True,
)
except ReleaseError:
return ""
def get_release_commits(
repository_root: Path,
previous_tag: str,
) -> list[str]:
revision_range = f"{previous_tag}..HEAD" if previous_tag else "HEAD"
output = run_git(
"log",
"--no-merges",
"--reverse",
"--pretty=tformat:%h %s",
revision_range,
cwd=repository_root,
capture_output=True,
)
return output.splitlines()
def print_release_notes_preview(repository_root: Path) -> None:
previous_tag = get_previous_tag(repository_root)
commits = get_release_commits(repository_root, previous_tag)
print()
if previous_tag:
print(f"Commits since {previous_tag} ({len(commits)}):")
else:
print(f"Commits in this release ({len(commits)}):")
if not commits:
print(" (none)")
for commit in commits:
print(f" {commit}")
print()
print("These commits go into the generated release notes.")
def read_version(props_path: Path) -> str:
if not props_path.exists():
raise ReleaseError(f"Version file not found: {props_path}")
@@ -323,6 +379,8 @@ def create_release_tag(
remote,
)
print_release_notes_preview(repository_root)
run_git(
"tag",
"-a",
+3 -4
View File
@@ -111,15 +111,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
name. -->
vs. target-RID mixups) for no benefit. Runtime code (FfmpegRuntime)
uses the same literal "plugins" folder name. -->
<PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup>
<PropertyGroup>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeTag>3b502d4</FfmpegRuntimeTag>
<FfmpegRuntimeDir>
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
@@ -214,6 +214,12 @@ public sealed partial class DirectExecutionBackend
Console.Error.WriteLine($"[LOADER][TRACE] Raw sentinel recoveries: {num2} (last import index={importIndex})");
_lastReportedRawSentinelRecoveries = num2;
}
if (importStubEntry.IsLeaf &&
TryDispatchHotMemoryLeaf(cpuContext, importStubEntry, argPackPtr, out var hotMemoryResult))
{
return hotMemoryResult;
}
if (importStubEntry.IsLeaf &&
TryDispatchLeafImport(cpuContext, importStubEntry, argPackPtr, num, out var leafResult))
{
@@ -381,7 +387,8 @@ public sealed partial class DirectExecutionBackend
bool flag4 = !string.IsNullOrWhiteSpace(_importFilter);
bool flag5 = false;
ExportedFunction? matchedExport = importStubEntry.Export;
bool periodicTrace = num <= 128 ||
bool periodicTrace = _logImportPeriodic &&
(num <= 128 ||
(num >= 240 && num <= 400) ||
(num >= 900 && num <= 1300) ||
num % 100000 == 0L ||
@@ -389,7 +396,7 @@ public sealed partial class DirectExecutionBackend
(importStubEntry.Nid == "rTXw65xmLIA" && (num <= 256 || num % 128 == 0)) ||
flag ||
flag2 ||
flag3;
flag3);
if (matchedExport is not null)
{
if (flag4)
@@ -1274,6 +1281,41 @@ public sealed partial class DirectExecutionBackend
Mxcsr: context.Mxcsr,
RestoreFullFpuState: false);
/// <summary>
/// Ultra-thin path for hot memcpy/memmove leaf imports: skip
/// CpuContext register marshalling, import-call frames, and vector return
/// stores when guest memory can satisfy the copy directly.
/// </summary>
private unsafe bool TryDispatchHotMemoryLeaf(
CpuContext cpuContext,
ImportStubEntry importStubEntry,
nint argPackPtr,
out ulong result)
{
result = 0;
var nid = importStubEntry.Nid;
if (nid is not ("Q3VBxCXhUHs" or "+P6FRGH4LfA"))
{
return false;
}
var destination = *(ulong*)argPackPtr;
var source = *(ulong*)(argPackPtr + 8);
var count = *(ulong*)(argPackPtr + 16);
if (count > (ulong)int.MaxValue)
{
return false;
}
if (count != 0 && !cpuContext.Memory.TryCopy(destination, source, count))
{
return false;
}
result = destination;
return true;
}
private unsafe bool TryDispatchLeafImport(
CpuContext cpuContext,
ImportStubEntry importStubEntry,
@@ -1337,7 +1379,7 @@ public sealed partial class DirectExecutionBackend
Volatile.Write(ref activeGuestThreadState.LastReturnRip, returnRip);
Volatile.Write(ref activeGuestThreadState.LastImportNid, importStubEntry.Nid);
}
if (dispatchIndex % 100000 == 0)
if (_logImportPeriodic && dispatchIndex % 100000 == 0)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] Import#{dispatchIndex}: {export.LibraryName}:{export.Name} ({importStubEntry.Nid}) " +
@@ -1471,7 +1513,12 @@ public sealed partial class DirectExecutionBackend
"xk0AcarP3V4" or // scePadOpen
"yH17Q6NWtVg" or // sceUserServiceGetEvent
"D-CzAxQL0XI" or // sceUserServiceGetPlatformPrivacySetting
"K-jXhbt2gn4"; // scePthreadMutexTrylock
"K-jXhbt2gn4" or // scePthreadMutexTrylock
// Hot memory leaves: skip non-NoBlock call-frame bookkeeping on top of TryCopy.
"Q3VBxCXhUHs" or // memcpy
"+P6FRGH4LfA" or // memmove
"DfivPArhucg" or // memcmp
"8zTFvBIAIN8"; // memset
private bool ShouldLogImportResult(string nid, OrbisGen2Result result)
{
@@ -344,6 +344,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private bool _logAllImports;
private bool _logImportPeriodic;
private bool _logImportFrames;
private bool _logImportRecent;
@@ -1161,6 +1163,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_logFiber = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER"), "1", StringComparison.Ordinal);
_logBootstrap = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_BOOTSTRAP"), "1", StringComparison.Ordinal);
_logAllImports = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_ALL_IMPORTS"), "1", StringComparison.Ordinal);
// Periodic Import# spam (every 100k, early bands, NID samples) is on
// only when explicitly requested — default stderr traffic was a measurable tax.
_logImportPeriodic = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_PERIODIC"),
"1",
StringComparison.Ordinal);
_logImportFrames = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_FRAMES"), "1", StringComparison.Ordinal);
_logImportRecent = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_RECENT"), "1", StringComparison.Ordinal);
_logStackCheck = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STACK_CHK"), "1", StringComparison.Ordinal);
@@ -401,6 +401,9 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
}
Environment.SetEnvironmentVariable(app0VariableName, app0Root);
// Overlap the cooked-id APR walk with module load so the first ReadFile
// miss mid-boot does not stall on a cold USB index.
SharpEmu.Libs.Ampr.AmprFileRegistry.BeginApp0IndexPreload(app0Root);
return new App0BindingScope(app0VariableName);
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Stateless trailing action that opens the library folder picker.
/// </summary>
public sealed class AddFolderTile : LibraryTile
{
public static AddFolderTile Instance { get; } = new();
private AddFolderTile()
{
}
}
+6
View File
@@ -15,6 +15,8 @@ cascade order so individual launcher views do not redefine global visuals.
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Tokens.axaml" />
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SettingRow.axaml" />
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/OptionsControls.axaml" />
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SplitNumericUpDown.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
@@ -25,8 +27,12 @@ cascade order so individual launcher views do not redefine global visuals.
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Base.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Surfaces.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Buttons.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Chrome.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/OptionsNav.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Options.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/GameOptions.axaml" />
</Application.Styles>
</Application>
+1
View File
@@ -18,6 +18,7 @@ public partial class App : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
desktop.MainWindow = new MainWindow();
}
@@ -0,0 +1,11 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia.Controls;
namespace SharpEmu.GUI.Controls.Settings;
/// <summary>
/// Numeric input whose native spinner is presented as a separate split button
/// </summary>
public sealed class SplitNumericUpDown : NumericUpDown;
+125 -13
View File
@@ -8,7 +8,16 @@ using Avalonia.Media.Imaging;
namespace SharpEmu.GUI;
public sealed class GameEntry : INotifyPropertyChanged
[Flags]
internal enum GameEntryChanges
{
None = 0,
Metadata = 1,
Cover = 2,
Background = 4,
}
public sealed class GameEntry : LibraryTile, INotifyPropertyChanged
{
// Placeholder gradients for games without cover art, picked
// deterministically from the game name so a game keeps its color.
@@ -27,29 +36,39 @@ public sealed class GameEntry : INotifyPropertyChanged
private Bitmap? _cover;
private IBrush? _placeholderBrush;
private long _sizeBytes;
private string _name;
private string? _titleId;
private string? _version;
private string? _coverPath;
private string? _backgroundPath;
private string _initials;
private FileStamp _coverStamp;
private FileStamp _backgroundStamp;
public GameEntry(
string name, string? titleId, string? version, string path, long sizeBytes,
string? coverPath, string? backgroundPath)
{
Name = name;
TitleId = titleId;
Version = version;
_name = name;
_titleId = titleId;
_version = version;
Path = path;
_sizeBytes = sizeBytes;
CoverPath = coverPath;
BackgroundPath = backgroundPath;
Initials = ComputeInitials(name);
_coverPath = coverPath;
_backgroundPath = backgroundPath;
_initials = ComputeInitials(name);
_coverStamp = FileStamp.Read(coverPath);
_backgroundStamp = FileStamp.Read(backgroundPath);
}
public event PropertyChangedEventHandler? PropertyChanged;
public string Name { get; }
public string Name => _name;
public string? TitleId { get; }
public string? TitleId => _titleId;
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
public string? Version { get; }
public string? Version => _version;
public string Path { get; }
@@ -75,10 +94,10 @@ public sealed class GameEntry : INotifyPropertyChanged
}
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
public string? CoverPath { get; }
public string? CoverPath => _coverPath;
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
public string? BackgroundPath { get; }
public string? BackgroundPath => _backgroundPath;
/// <summary>
/// Decoded key art used as the window backdrop while this game is
@@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged
/// </summary>
public Bitmap? Background { get; set; }
public string Initials { get; }
public string Initials => _initials;
// Built lazily: brushes are AvaloniaObjects that must be created on the
// UI thread, while GameEntry itself is constructed on the scan thread.
@@ -121,6 +140,74 @@ public sealed class GameEntry : INotifyPropertyChanged
/// <summary>Formatted install size badge shown in the launch bar.</summary>
public string SizeText => FormatSize(SizeBytes);
/// <summary>
/// Applies a fresh filesystem scan to this presentation object so its
/// ListBox container and selection remain stable
/// </summary>
internal GameEntryChanges UpdateFrom(GameEntry scanned)
{
if (!Path.Equals(scanned.Path, GameLibraryPath.Comparison))
{
throw new ArgumentException("Only matching game paths can be reconciled", nameof(scanned));
}
var changes = GameEntryChanges.None;
if (!string.Equals(_name, scanned.Name, StringComparison.Ordinal))
{
_name = scanned.Name;
_initials = ComputeInitials(scanned.Name);
_placeholderBrush = null;
RaisePropertyChanged(nameof(Name));
RaisePropertyChanged(nameof(Initials));
RaisePropertyChanged(nameof(PlaceholderBrush));
changes |= GameEntryChanges.Metadata;
}
if (!string.Equals(_titleId, scanned.TitleId, StringComparison.Ordinal))
{
_titleId = scanned.TitleId;
RaisePropertyChanged(nameof(TitleId));
RaisePropertyChanged(nameof(HasTitleId));
changes |= GameEntryChanges.Metadata;
}
if (!string.Equals(_version, scanned.Version, StringComparison.Ordinal))
{
_version = scanned.Version;
RaisePropertyChanged(nameof(Version));
RaisePropertyChanged(nameof(VersionText));
RaisePropertyChanged(nameof(HasVersion));
changes |= GameEntryChanges.Metadata;
}
if (!string.Equals(_coverPath, scanned.CoverPath, StringComparison.Ordinal)
|| _coverStamp != scanned._coverStamp)
{
_coverPath = scanned.CoverPath;
_coverStamp = scanned._coverStamp;
var previousCover = Cover;
Cover = null;
previousCover?.Dispose();
changes |= GameEntryChanges.Cover;
}
if (!string.Equals(_backgroundPath, scanned.BackgroundPath, StringComparison.Ordinal)
|| _backgroundStamp != scanned._backgroundStamp)
{
_backgroundPath = scanned.BackgroundPath;
_backgroundStamp = scanned._backgroundStamp;
var previousBackground = Background;
Background = null;
previousBackground?.Dispose();
changes |= GameEntryChanges.Background;
}
return changes;
}
private void RaisePropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private static string ComputeInitials(string name)
{
var initials = name
@@ -164,4 +251,29 @@ public sealed class GameEntry : INotifyPropertyChanged
_ => $"{bytes} B",
};
}
private readonly record struct FileStamp(long Length, long LastWriteTimeUtcTicks)
{
public static FileStamp Read(string? path)
{
if (path is null)
{
return default;
}
try
{
var file = new FileInfo(path);
return file.Exists
? new FileStamp(file.Length, file.LastWriteTimeUtc.Ticks)
: default;
}
catch (Exception exception) when (
exception is IOException
or UnauthorizedAccessException)
{
return default;
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
internal static class GameLibraryPath
{
public static StringComparer Comparer { get; } =
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
public static StringComparison Comparison { get; } =
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
internal sealed record GameLibraryReconciliation(
IReadOnlyList<GameEntry> Games,
IReadOnlyList<GameEntry> CoversToLoad,
IReadOnlySet<GameEntry> BackgroundsChanged);
/// <summary>
/// Applies filesystem scan results while preserving presentation object
/// identity for games that remain in the library
/// </summary>
internal static class GameLibraryReconciler
{
public static GameLibraryReconciliation Reconcile(
IReadOnlyList<GameEntry> current,
IReadOnlyList<GameEntry> scanned)
{
var existingByPath = current.ToDictionary(game => game.Path, GameLibraryPath.Comparer);
var merged = new List<GameEntry>(scanned.Count);
var coversToLoad = new List<GameEntry>();
var backgroundsChanged = new HashSet<GameEntry>();
foreach (var scannedGame in scanned)
{
if (!existingByPath.TryGetValue(scannedGame.Path, out var existing))
{
merged.Add(scannedGame);
if (scannedGame.CoverPath is not null)
{
coversToLoad.Add(scannedGame);
}
continue;
}
var changes = existing.UpdateFrom(scannedGame);
merged.Add(existing);
if ((changes & GameEntryChanges.Cover) != 0 && existing.CoverPath is not null)
{
coversToLoad.Add(existing);
}
if ((changes & GameEntryChanges.Background) != 0)
{
backgroundsChanged.Add(existing);
}
}
return new GameLibraryReconciliation(merged, coversToLoad, backgroundsChanged);
}
/// <summary>
/// Reorders, inserts and removes only the items needed to reach the desired
/// visible sequence
/// </summary>
public static void ReconcileVisibleGames(
IList<GameEntry> visible,
IReadOnlyList<GameEntry> desired)
{
for (var index = 0; index < desired.Count; index++)
{
var game = desired[index];
if (index < visible.Count && ReferenceEquals(visible[index], game))
{
continue;
}
var existingIndex = -1;
for (var candidate = index + 1; candidate < visible.Count; candidate++)
{
if (ReferenceEquals(visible[candidate], game))
{
existingIndex = candidate;
break;
}
}
if (existingIndex >= 0)
{
var existing = visible[existingIndex];
visible.RemoveAt(existingIndex);
visible.Insert(index, existing);
}
else
{
visible.Insert(index, game);
}
}
while (visible.Count > desired.Count)
{
visible.RemoveAt(visible.Count - 1);
}
}
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Watches configured game folders and collapses filesystem bursts into one
/// library refresh request
/// </summary>
internal sealed class GameLibraryWatcher : IDisposable
{
private static readonly TimeSpan DefaultDebounceInterval = TimeSpan.FromMilliseconds(600);
private readonly object _sync = new();
private readonly TimeSpan _debounceInterval;
private readonly List<FileSystemWatcher> _watchers = [];
private Timer? _debounceTimer;
private bool _disposed;
internal GameLibraryWatcher(TimeSpan? debounceInterval = null)
{
_debounceInterval = debounceInterval ?? DefaultDebounceInterval;
}
public event EventHandler? RefreshRequested;
/// <summary>
/// Replaces the watched roots with the current configured game folders
/// </summary>
public void Watch(IReadOnlyList<string> folders)
{
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
foreach (var watcher in _watchers)
{
watcher.Dispose();
}
_watchers.Clear();
_debounceTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
foreach (var folder in folders.Distinct(GameLibraryPath.Comparer))
{
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
{
continue;
}
try
{
var watcher = new FileSystemWatcher(folder)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName
| NotifyFilters.DirectoryName
| NotifyFilters.LastWrite
| NotifyFilters.Size,
};
watcher.Created += OnFileSystemChanged;
watcher.Changed += OnFileSystemChanged;
watcher.Deleted += OnFileSystemChanged;
watcher.Renamed += OnFileSystemChanged;
watcher.Error += OnWatcherError;
watcher.EnableRaisingEvents = true;
_watchers.Add(watcher);
}
catch (Exception exception) when (
exception is ArgumentException
or IOException
or UnauthorizedAccessException)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not watch game folder '{folder}': {exception.Message}");
}
}
}
}
private void OnFileSystemChanged(object sender, FileSystemEventArgs args)
=> ScheduleRefresh();
private void OnWatcherError(object sender, ErrorEventArgs args)
{
Console.Error.WriteLine(
$"[GUI][WARN] Game library watcher reported an error: {args.GetException().Message}");
ScheduleRefresh();
}
private void ScheduleRefresh()
{
lock (_sync)
{
if (_disposed)
{
return;
}
if (_debounceTimer is null)
{
_debounceTimer = new Timer(
static state => ((GameLibraryWatcher)state!).RaiseRefreshRequested(),
this,
_debounceInterval,
Timeout.InfiniteTimeSpan);
}
else
{
_debounceTimer.Change(_debounceInterval, Timeout.InfiniteTimeSpan);
}
}
}
private void RaiseRefreshRequested()
{
lock (_sync)
{
if (_disposed)
{
return;
}
}
RefreshRequested?.Invoke(this, EventArgs.Empty);
}
public void Dispose()
{
lock (_sync)
{
if (_disposed)
{
return;
}
_disposed = true;
_debounceTimer?.Dispose();
_debounceTimer = null;
foreach (var watcher in _watchers)
{
watcher.Dispose();
}
_watchers.Clear();
}
}
}
+24
View File
@@ -40,11 +40,16 @@ public sealed class GuiSettings
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
public bool PlayTitleMusic { get; set; } = true;
public string LibraryLayout { get; set; } = "Carousel";
public string? EmulatorPath { get; set; }
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
public string Language { get; set; } = "en";
/// <summary>Default text-entry profile exposed to games.</summary>
public string DefaultProfile { get; set; } = "Sharp";
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
public bool DiscordRichPresence { get; set; } = true;
@@ -112,11 +117,24 @@ public sealed class GuiSettings
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
settings.LogLevel ??= "Info";
settings.Language ??= "en";
var legacyProfile = settings.EnvironmentToggles
.Select(entry => entry.Split('=', 2, StringSplitOptions.TrimEntries))
.FirstOrDefault(parts =>
parts.Length == 2 &&
string.Equals(parts[0], "SHARPEMU_DEFAULT_PROFILE", StringComparison.OrdinalIgnoreCase));
settings.EnvironmentToggles.RemoveAll(entry =>
string.Equals(
entry.Split('=', 2, StringSplitOptions.TrimEntries)[0],
"SHARPEMU_DEFAULT_PROFILE",
StringComparison.OrdinalIgnoreCase));
settings.DefaultProfile = NormalizeDefaultProfile(
legacyProfile is { Length: 2 } ? legacyProfile[1] : settings.DefaultProfile);
settings.DiscordClientId ??= "1525606762248540221";
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
{
settings.RenderResolutionScale = 1.0;
}
settings.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
settings.Resolution = NormalizeResolution(settings.Resolution);
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
@@ -152,6 +170,12 @@ public sealed class GuiSettings
return $"{width}x{height}";
}
internal static string NormalizeDefaultProfile(string? value)
{
var trimmed = value?.Trim();
return string.IsNullOrEmpty(trimmed) ? "Sharp" : trimmed;
}
public void Save()
{
try
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} لعبة",
"Library.SearchWatermark": "ابحث في المكتبة...",
"Library.AddFolder": " إضافة مجلد",
"Library.Rescan": "⟳ إعادة الفحص",
"Library.AddFolder": "إضافة مجلد",
"Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": "+ إضافة مجلد ألعاب",
"Library.Loading": "جارٍ تحميل المكتبة...",
"Library.Stat.Version": "الإصدار",
"Library.Stat.Installed": "المثبت",
"Library.Stat.TitleId": "معرّف اللعبة",
"Common.Back": "رجوع",
"Options.General": "عام",
"Options.Logging": "التسجيل",
"Options.Section.Emulation": "المحاكاة",
"Options.Section.Logging": "التسجيل",
"Options.Section.Launcher": "المُشغِّل",
@@ -67,6 +71,8 @@
"Options.Language.Label": "لغة المحاكي",
"Options.Language.Desc": "اللغة المستخدمة في جميع أنحاء المشغل. تُطبق فوراً.",
"Options.DefaultProfile.Label": "اسم الملف الشخصي الافتراضي",
"Options.DefaultProfile.Desc": "الاسم المستخدم عندما تطلب لعبة إدخال نص. القيمة الافتراضية هي Sharp.",
"Common.On": "تشغيل",
"Common.Off": "إيقاف",
@@ -139,12 +145,9 @@
"Options.Env.LogDirectMemory.Desc": "تسجيل تخصيصات الذاكرة المباشرة وإخفاقاتها في وحدة التحكم.\nاستخدمه عندما تنهار لعبة أو تُغلق أثناء الإقلاع.",
"Options.Env.LogIo.Desc": "تسجيل فتح الملفات وقراءتها وحلّ المسارات في وحدة التحكم.\nاستخدمه عندما لا تجد لعبة ملفات بياناتها أثناء الإقلاع.",
"Options.Env.LogNp.Desc": "تسجيل نداءات مكتبة NP (شبكة PlayStation) في وحدة التحكم.",
"Options.Env.GuestImageCpuSync.Desc": "إعادة رفع أسطح الضيف التي تعيد كتابتها شيفرة المعالج الخاصة باللعبة.\nاتركه مغلقًا عادة. شغّله للألعاب التي لا تصل أسطحها المرسومة بالمعالج إلى الشاشة.\nيكلّف أداءً ويسبب مشاكل في بعض الألعاب مثل GTA V.",
"Common.Save": "حفظ",
"Common.Cancel": "إلغاء",
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح ‎SHARPEMU_*‎ لهذه اللعبة.",
"Options.About": "حول",
"About.Github.Label": "GitHub",
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
@@ -153,7 +156,7 @@
"About.Discord.Label": "دسكورد",
"About.Discord.Desc": "انضم إلى المجتمع واحصل على الدعم وتابع التطوير.",
"About.GithubButton": "ساهم على GitHub!",
"About.DiscordButton": "انضم إلى دسكوردنا!",
"About.DiscordComingSoon": "قريبًا",
"Updater.Auto.Label": "التحقق من التحديثات عند بدء التشغيل",
"Updater.Auto.Desc": "يتحقق من GitHub دون تأخير بدء التشغيل.",
"Updater.Label": "التحديثات",
@@ -168,5 +171,34 @@
"Updater.Status.Timeout": "انتهت مهلة التحقق من التحديثات بعد 10 ثوانٍ.",
"Updater.Status.Failed": "تعذر التحقق من التحديثات.",
"Updater.Status.ChecksumFailed": "فشل التحديث المنزَّل في اجتياز تحقق SHA-256.",
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS."
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS.",
"Options.Graphics": "الرسومات",
"Options.Section.Rendering": "التصيير",
"Options.Section.Display": "العرض",
"Options.RenderResolution.Label": "الدقة الداخلية",
"Options.RenderResolution.Desc": "تصيير الأهداف خارج الشاشة بدقة أقل من الدقة الأصلية ثم رفع دقتها عند العرض. تمنح القيم الأقل مساحة أكبر لوحدة معالجة الرسوميات مقابل جودة الصورة؛ يُطبق عند التشغيل التالي.",
"Options.RenderResolution.Native": "100% (أصلية)",
"Options.WindowMode.Label": "وضع النافذة",
"Options.WindowMode.Desc": "نافذة عادية، أو سطح مكتب بلا حدود، أو ملء شاشة حصري.",
"Options.WindowMode.Windowed": "نافذة",
"Options.WindowMode.Borderless": "بلا حدود",
"Options.WindowMode.Exclusive": "حصري",
"Options.Resolution.Label": "الدقة",
"Options.Resolution.Desc": "حجم النافذة الأولي أو دقة ملء الشاشة الحصرية.",
"Options.Display.Label": "الشاشة",
"Options.Display.Desc": "الشاشة المستخدمة للتوسيط وملء الشاشة.",
"Options.RefreshRate.Label": "معدل التحديث",
"Options.RefreshRate.Desc": "معدل تحديث ملء الشاشة الحصري. يختار الوضع التلقائي أقرب نمط.",
"Options.RefreshRate.Automatic": "تلقائي",
"Options.Scaling.Label": "التحجيم",
"Options.Scaling.Desc": "تحجيم صورة النظام الضيف الأصلية دون تغيير دقتها الداخلية.",
"Options.Scaling.Fit": "ملاءمة",
"Options.Scaling.Cover": "تغطية",
"Options.Scaling.Stretch": "تمديد",
"Options.Scaling.Integer": "عدد صحيح",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "استخدام عرض FIFO لإخراج خالٍ من تمزق الصورة.",
"Options.Hdr.Label": "إخراج HDR",
"Options.Hdr.Desc": "استخدام HDR عندما تدعمه الشاشة المحددة وواجهة الرسومات. يعود الوضع التلقائي إلى SDR.",
"Options.Hdr.Auto": "تلقائي"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar na biblioteca…",
"Library.AddFolder": " Adicionar pasta",
"Library.Rescan": "⟳ Atualizar biblioteca",
"Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Adicionar pasta do jogo",
"Library.Loading": "Carregando biblioteca…",
"Library.Stat.Version": "Versão",
"Library.Stat.Installed": "Instalado",
"Library.Stat.TitleId": "ID do título",
"Common.Back": "Voltar",
"Options.General": "Opções Gerais",
"Options.Logging": "Logs",
"Options.Env.Tab": "Ambiente",
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
"Options.Env.Desc": "Variáveis de ambiente passadas ao emulador durante a inicialização.",
@@ -35,6 +39,7 @@
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e suas traduções SPIR-V para a pasta shader-dumps.\nUse ao reportar bugs de shader ou renderização.",
"Options.Env.LogDirectMemory.Desc": "Registra alocações de memória direta e falhas no console.\nUse quando um jogo aborta ou fecha durante a inicialização (boot).",
"Options.Env.LogNp.Desc": "Registra chamadas da biblioteca NP (PlayStation Network) no console.",
"Options.Env.GuestImageCpuSync.Desc": "Recarrega as superfícies do convidado que o próprio código de CPU do jogo reescreve.\nDeixe desativado normalmente. Ative para títulos cujas superfícies desenhadas pela CPU nunca chegam à tela.\nCusta desempenho e causa regressões em alguns títulos, como GTA V.",
"Options.Section.Emulation": "EMULAÇÃO",
"Options.Section.Logging": "LOGS",
"Options.Section.Launcher": "INICIALIZADOR",
@@ -76,6 +81,8 @@
"Options.Language.Label": "Idioma do emulador",
"Options.Language.Desc": "Idioma usado em toda a interface do emulador. A alteração é aplicada imediatamente.",
"Options.DefaultProfile.Label": "Nome de perfil padrão",
"Options.DefaultProfile.Desc": "Nome usado quando um jogo solicita entrada de texto. O padrão é Sharp.",
"Common.On": "Ativado",
"Common.Off": "Desativado",
@@ -142,17 +149,13 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Entre no nosso Discord!",
"About.DiscordComingSoon": "Em breve",
"Library.Context.GameSettings": "Configurações do jogo…",
"Options.Env.WritableApp0.Desc": "Permite que os jogos criem e gravem arquivos dentro da própria pasta de instalação.\nNecessário para dumps não empacotados que gravam seus saves ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
"Common.Save": "Salvar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Configurações por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit na branch main",
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
@@ -169,5 +172,34 @@
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível verificar as atualizações.",
"Updater.Status.ChecksumFailed": "A atualização baixada falhou na verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS.",
"Options.Graphics": "Gráficos",
"Options.Section.Rendering": "RENDERIZAÇÃO",
"Options.Section.Display": "TELA",
"Options.RenderResolution.Label": "Resolução interna",
"Options.RenderResolution.Desc": "Renderiza alvos fora da tela abaixo da resolução nativa e amplia na apresentação. Valores menores trocam qualidade de imagem por folga da GPU; entra em vigor na próxima inicialização.",
"Options.RenderResolution.Native": "100% (nativa)",
"Options.WindowMode.Label": "Modo de janela",
"Options.WindowMode.Desc": "Janela normal, área de trabalho sem bordas ou tela cheia exclusiva.",
"Options.WindowMode.Windowed": "Em janela",
"Options.WindowMode.Borderless": "Sem bordas",
"Options.WindowMode.Exclusive": "Exclusiva",
"Options.Resolution.Label": "Resolução",
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de tela cheia exclusiva.",
"Options.Display.Label": "Tela",
"Options.Display.Desc": "Monitor usado para centralização e tela cheia.",
"Options.RefreshRate.Label": "Taxa de atualização",
"Options.RefreshRate.Desc": "Taxa de atualização da tela cheia exclusiva. O modo automático seleciona o modo mais próximo.",
"Options.RefreshRate.Automatic": "Automática",
"Options.Scaling.Label": "Escala",
"Options.Scaling.Desc": "Dimensiona a imagem nativa do sistema convidado sem alterar sua resolução interna.",
"Options.Scaling.Fit": "Ajustar",
"Options.Scaling.Cover": "Preencher",
"Options.Scaling.Stretch": "Esticar",
"Options.Scaling.Integer": "Inteira",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Usa apresentação FIFO para evitar cortes na imagem.",
"Options.Hdr.Label": "Saída HDR",
"Options.Hdr.Desc": "Usa HDR quando a tela selecionada e o backend gráfico oferecem suporte. O modo automático retorna ao SDR.",
"Options.Hdr.Auto": "Automático"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} Spiele",
"Library.SearchWatermark": "Bibliothek durchsuchen…",
"Library.AddFolder": " Spielordner hinzufügen",
"Library.Rescan": "⟳ Neu scannen",
"Library.AddFolder": "Spielordner hinzufügen",
"Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Spielordner hinzufügen",
"Library.Loading": "Bibliothek wird geladen…",
"Library.Stat.Version": "Version",
"Library.Stat.Installed": "Installiert",
"Library.Stat.TitleId": "Titel-ID",
"Common.Back": "Zurück",
"Options.General": "Allgemein",
"Options.Logging": "Protokollierung",
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "PROTOKOLLIERUNG",
"Options.Section.Launcher": "LAUNCHER",
@@ -67,6 +71,8 @@
"Options.Language.Label": "Emulator-Sprache",
"Options.Language.Desc": "Sprache der Benutzeroberfläche. Wird sofort angewendet.",
"Options.DefaultProfile.Label": "Standardprofilname",
"Options.DefaultProfile.Desc": "Name für Texteingaben in Spielen. Der Standardwert ist Sharp.",
"Common.On": "An",
"Common.Off": "Aus",
@@ -139,12 +145,9 @@
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
"Options.Env.GuestImageCpuSync.Desc": "Gast-Oberflächen neu hochladen, die der eigene CPU-Code des Spiels überschreibt.\nNormalerweise aus lassen. Für Titel aktivieren, deren CPU-gezeichnete Oberflächen nie auf dem Bildschirm erscheinen.\nKostet Leistung und verursacht bei einigen Titeln wie GTA V Regressionen.",
"Common.Save": "Speichern",
"Common.Cancel": "Abbrechen",
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
"PerGame.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
"PerGame.EnvToggles.Label": "Umgebungsschalter",
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
"Options.About": "Über",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
@@ -153,7 +156,7 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Tritt der Community bei, erhalte Support und verfolge die Entwicklung.",
"About.GithubButton": "Auf GitHub mitwirken!",
"About.DiscordButton": "Tritt unserem Discord bei!",
"About.DiscordComingSoon": "Demnächst verfügbar",
"Updater.Auto.Label": "Beim Start nach Updates suchen",
"Updater.Auto.Desc": "Fragt GitHub ab, ohne den Start zu verzögern.",
"Updater.Label": "Updates",
@@ -168,5 +171,34 @@
"Updater.Status.Timeout": "Die Updateprüfung ist nach 10 Sekunden abgelaufen.",
"Updater.Status.Failed": "Updates konnten nicht geprüft werden.",
"Updater.Status.ChecksumFailed": "Das heruntergeladene Update hat die SHA-256-Prüfung nicht bestanden.",
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS."
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS.",
"Options.Graphics": "Grafik",
"Options.Section.Rendering": "DARSTELLUNG",
"Options.Section.Display": "ANZEIGE",
"Options.RenderResolution.Label": "Interne Auflösung",
"Options.RenderResolution.Desc": "Offscreen-Ziele unterhalb der nativen Auflösung rendern und bei der Ausgabe hochskalieren. Niedrigere Werte tauschen Bildqualität gegen GPU-Reserven; wird beim nächsten Start wirksam.",
"Options.RenderResolution.Native": "100 % (nativ)",
"Options.WindowMode.Label": "Fenstermodus",
"Options.WindowMode.Desc": "Normales Fenster, randloser Desktop oder exklusiver Vollbildmodus.",
"Options.WindowMode.Windowed": "Fenster",
"Options.WindowMode.Borderless": "Randlos",
"Options.WindowMode.Exclusive": "Exklusiv",
"Options.Resolution.Label": "Auflösung",
"Options.Resolution.Desc": "Anfängliche Fenstergröße oder exklusive Vollbildauflösung.",
"Options.Display.Label": "Anzeige",
"Options.Display.Desc": "Monitor für Zentrierung und Vollbilddarstellung.",
"Options.RefreshRate.Label": "Bildwiederholrate",
"Options.RefreshRate.Desc": "Bildwiederholrate im exklusiven Vollbildmodus. Automatisch wählt den nächstgelegenen Modus.",
"Options.RefreshRate.Automatic": "Automatisch",
"Options.Scaling.Label": "Skalierung",
"Options.Scaling.Desc": "Das native Gastbild skalieren, ohne seine interne Auflösung zu ändern.",
"Options.Scaling.Fit": "Einpassen",
"Options.Scaling.Cover": "Ausfüllen",
"Options.Scaling.Stretch": "Strecken",
"Options.Scaling.Integer": "Ganzzahlig",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "FIFO-Präsentation für eine Ausgabe ohne Tearing verwenden.",
"Options.Hdr.Label": "HDR-Ausgabe",
"Options.Hdr.Desc": "HDR verwenden, wenn die ausgewählte Anzeige und das Grafik-Backend es unterstützen. Automatisch fällt auf SDR zurück.",
"Options.Hdr.Auto": "Automatisch"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} spil",
"Library.SearchWatermark": "Søg i biblioteket…",
"Library.AddFolder": " Tilføj mappe",
"Library.Rescan": "⟳ Genindlæs",
"Library.AddFolder": "Tilføj mappe",
"Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Tilføj spilmappe",
"Library.Loading": "Indlæser bibliotek…",
"Library.Stat.Version": "Version",
"Library.Stat.Installed": "Installeret",
"Library.Stat.TitleId": "Titel-id",
"Common.Back": "Tilbage",
"Options.General": "Generelt",
"Options.Logging": "Logging",
"Options.Section.Emulation": "EMULERING",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
@@ -67,6 +71,8 @@
"Options.Language.Label": "Emulatorsprog",
"Options.Language.Desc": "Sprog der bruges i hele launcheren. Anvendes med det samme.",
"Options.DefaultProfile.Label": "Standardprofilnavn",
"Options.DefaultProfile.Desc": "Navn der bruges, når et spil beder om tekstinput. Standardværdien er Sharp.",
"Common.On": "Til",
"Common.Off": "Fra",
@@ -139,12 +145,9 @@
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
"Options.Env.GuestImageCpuSync.Desc": "Genindlæs gæsteoverflader, som spillets egen CPU-kode omskriver.\nLad den være slået fra normalt. Slå til for titler, hvis CPU-tegnede overflader aldrig når skærmen.\nKoster ydeevne og giver regressioner i nogle titler, såsom GTA V.",
"Common.Save": "Gem",
"Common.Cancel": "Annuller",
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
"PerGame.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
"PerGame.EnvToggles.Label": "Miljøkontakter",
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
"Options.About": "Om",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
@@ -153,7 +156,7 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Bliv en del af fællesskabet, få hjælp og følg udviklingen.",
"About.GithubButton": "Bidrag på GitHub!",
"About.DiscordButton": "Bliv medlem af vores Discord!",
"About.DiscordComingSoon": "Kommer snart",
"Updater.Auto.Label": "Søg efter opdateringer ved start",
"Updater.Auto.Desc": "Tjekker GitHub uden at forsinke opstarten.",
"Updater.Label": "Opdateringer",
@@ -168,5 +171,34 @@
"Updater.Status.Timeout": "Opdateringstjekket fik timeout efter 10 sekunder.",
"Updater.Status.Failed": "Kunne ikke søge efter opdateringer.",
"Updater.Status.ChecksumFailed": "Den downloadede opdatering bestod ikke SHA-256-verifikationen.",
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS."
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS.",
"Options.Graphics": "Grafik",
"Options.Section.Rendering": "GENGIVELSE",
"Options.Section.Display": "SKÆRM",
"Options.RenderResolution.Label": "Intern opløsning",
"Options.RenderResolution.Desc": "Render offscreen-mål under den oprindelige opløsning, og opskaler dem ved visning. Lavere værdier bytter billedkvalitet for GPU-kapacitet; træder i kraft ved næste start.",
"Options.RenderResolution.Native": "100 % (oprindelig)",
"Options.WindowMode.Label": "Vinduestilstand",
"Options.WindowMode.Desc": "Normalt vindue, kantløst skrivebord eller eksklusiv fuldskærm.",
"Options.WindowMode.Windowed": "Vindue",
"Options.WindowMode.Borderless": "Kantløs",
"Options.WindowMode.Exclusive": "Eksklusiv",
"Options.Resolution.Label": "Opløsning",
"Options.Resolution.Desc": "Oprindelig vinduesstørrelse eller opløsning i eksklusiv fuldskærm.",
"Options.Display.Label": "Skærm",
"Options.Display.Desc": "Skærm, der bruges til centrering og fuldskærm.",
"Options.RefreshRate.Label": "Opdateringshastighed",
"Options.RefreshRate.Desc": "Opdateringshastighed i eksklusiv fuldskærm. Automatisk vælger den nærmeste tilstand.",
"Options.RefreshRate.Automatic": "Automatisk",
"Options.Scaling.Label": "Skalering",
"Options.Scaling.Desc": "Skaler det oprindelige gæstebillede uden at ændre dets interne opløsning.",
"Options.Scaling.Fit": "Tilpas",
"Options.Scaling.Cover": "Udfyld",
"Options.Scaling.Stretch": "Stræk",
"Options.Scaling.Integer": "Heltal",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Brug FIFO-præsentation for output uden tearing.",
"Options.Hdr.Label": "HDR-output",
"Options.Hdr.Desc": "Brug HDR, når den valgte skærm og grafik-backend understøtter det. Automatisk falder tilbage til SDR.",
"Options.Hdr.Auto": "Automatisk"
}
+24 -9
View File
@@ -7,9 +7,10 @@
"Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Search library…",
"Library.AddFolder": " Add folder",
"Library.Rescan": "⟳ Rescan",
"Library.AddFolder": "Add folder",
"Library.OpenFile": "Open file…",
"Library.View.Grid": "Stacked view",
"Library.View.Carousel": "Row view",
"Library.Context.Launch": "Launch",
"Library.Context.OpenFolder": "Open game folder",
@@ -25,8 +26,13 @@
"Library.Empty.AddFolder": " Add game folder",
"Library.Loading": "Loading library…",
"Library.Stat.Version": "Version",
"Library.Stat.Installed": "Installed",
"Library.Stat.TitleId": "Title ID",
"Common.Back": "Back",
"Options.General": "General",
"Options.Logging": "Logging",
"Options.Env.Tab": "Environment",
"Options.Section.Environment": "ENVIRONMENT VARIABLES",
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
@@ -38,14 +44,24 @@
"Options.Env.LogDirectMemory.Desc": "Log direct memory allocations and failures to the console.\nUse when a game aborts or exits during boot.",
"Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
"Options.Env.GuestImageCpuSync.Desc": "Re-upload guest surfaces the game's own CPU code rewrites.\nLeave off normally. Turn on for titles whose CPU-drawn surfaces never reach the screen.\nCosts performance and regresses some titles, such as GTA V.",
"Options.DefaultProfile.Label": "Default profile name",
"Options.DefaultProfile.Desc": "Name used when a game asks for text input. Defaults to Sharp.",
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
"Options.Section.Rendering": "RENDERING",
"Options.Section.Display": "DISPLAY",
"Options.Graphics": "Graphics",
"Options.RenderResolution.Label": "Internal resolution",
"Options.RenderResolution.Desc": "Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.",
"Options.RenderResolution.Native": "100% (native)",
"Options.WindowMode.Label": "Window mode",
"Options.WindowMode.Desc": "Regular window, desktop borderless, or exclusive fullscreen.",
"Options.WindowMode.Windowed": "Windowed",
"Options.WindowMode.Borderless": "Borderless",
"Options.WindowMode.Exclusive": "Exclusive",
"Options.Resolution.Label": "Resolution",
"Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
"Options.Display.Label": "Display",
@@ -55,10 +71,15 @@
"Options.RefreshRate.Automatic": "Automatic",
"Options.Scaling.Label": "Scaling",
"Options.Scaling.Desc": "Scale the native guest image without changing its internal resolution.",
"Options.Scaling.Fit": "Fit",
"Options.Scaling.Cover": "Cover",
"Options.Scaling.Stretch": "Stretch",
"Options.Scaling.Integer": "Integer",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Use FIFO presentation for tear-free output.",
"Options.Hdr.Label": "HDR output",
"Options.Hdr.Desc": "Use HDR when the selected display and graphics backend support it. Auto falls back to SDR.",
"Options.Hdr.Auto": "Auto",
"Options.CpuEngine.Label": "CPU engine",
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
@@ -103,12 +124,6 @@
"Common.Save": "Save",
"Common.Cancel": "Cancel",
"PerGame.Title": "Per-game settings — {0} ({1})",
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
"PerGame.Tab.General": "General",
"PerGame.Tab.Graphics": "Graphics",
"PerGame.EnvToggles.Label": "Environment toggles",
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
"Console.Title": "CONSOLE",
"Console.SearchWatermark": "Search...",
@@ -174,7 +189,7 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Join the community, get support and follow development.",
"About.GithubButton": "Contribute in GitHub!",
"About.DiscordButton": "Join our Discord!",
"About.DiscordComingSoon": "Coming soon",
"Updater.Auto.Label": "Check for updates on startup",
"Updater.Auto.Desc": "Checks GitHub without delaying startup.",
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} juegos",
"Library.SearchWatermark": "Buscar en la biblioteca…",
"Library.AddFolder": " Añadir carpeta",
"Library.Rescan": "⟳ Volver a escanear",
"Library.AddFolder": "Añadir carpeta",
"Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Añadir carpeta de juegos",
"Library.Loading": "Cargando biblioteca…",
"Library.Stat.Version": "Versión",
"Library.Stat.Installed": "Instalado",
"Library.Stat.TitleId": "ID del título",
"Common.Back": "Volver",
"Options.General": "General",
"Options.Logging": "Logs",
"Options.Section.Emulation": "EMULACIÓN",
"Options.Section.Logging": "LOGS",
"Options.Section.Launcher": "LAUNCHER",
@@ -67,6 +71,8 @@
"Options.Language.Label": "Idioma del emulador",
"Options.Language.Desc": "Idioma utilizado en todo el launcher. Se aplica inmediatamente.",
"Options.DefaultProfile.Label": "Nombre de perfil predeterminado",
"Options.DefaultProfile.Desc": "Nombre utilizado cuando un juego solicita introducir texto. El valor predeterminado es Sharp.",
"Common.On": "Encendido",
"Common.Off": "Apagado",
@@ -135,7 +141,7 @@
"About.Github.LatestCommitDescription": "Último commit en la rama main",
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
"About.GithubButton": "Contribuye en GitHub!",
"About.DiscordButton": "Únete a nuestro Discord!",
"About.DiscordComingSoon": "Próximamente",
"Library.Context.GameSettings": "Ajustes del juego…",
"Options.Env.Tab": "Entorno",
@@ -149,12 +155,9 @@
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
"Options.Env.GuestImageCpuSync.Desc": "Volver a subir las superficies del invitado que reescribe el propio código de CPU del juego.\nDejar desactivado normalmente. Activar en títulos cuyas superficies dibujadas por CPU nunca llegan a la pantalla.\nCuesta rendimiento y causa regresiones en algunos títulos, como GTA V.",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Ajustes por juego — {0} ({1})",
"PerGame.InheritNote": "Las filas sin marcar heredan los valores globales.",
"PerGame.EnvToggles.Label": "Variables de entorno",
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
"Updater.Label": "Actualizaciones",
@@ -169,5 +172,34 @@
"Updater.Status.Timeout": "La comprobación de actualizaciones caducó tras 10 segundos.",
"Updater.Status.Failed": "No se pudieron comprobar las actualizaciones.",
"Updater.Status.ChecksumFailed": "La actualización descargada no superó la verificación SHA-256.",
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS."
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS.",
"Options.Graphics": "Gráficos",
"Options.Section.Rendering": "RENDERIZADO",
"Options.Section.Display": "PANTALLA",
"Options.RenderResolution.Label": "Resolución interna",
"Options.RenderResolution.Desc": "Renderiza objetivos fuera de pantalla por debajo de la resolución nativa y los reescala al presentar. Los valores inferiores sacrifican calidad de imagen para liberar carga de la GPU; se aplica en el próximo inicio.",
"Options.RenderResolution.Native": "100 % (nativa)",
"Options.WindowMode.Label": "Modo de ventana",
"Options.WindowMode.Desc": "Ventana normal, escritorio sin bordes o pantalla completa exclusiva.",
"Options.WindowMode.Windowed": "En ventana",
"Options.WindowMode.Borderless": "Sin bordes",
"Options.WindowMode.Exclusive": "Exclusiva",
"Options.Resolution.Label": "Resolución",
"Options.Resolution.Desc": "Tamaño inicial de la ventana o resolución de pantalla completa exclusiva.",
"Options.Display.Label": "Pantalla",
"Options.Display.Desc": "Monitor utilizado para centrar y mostrar a pantalla completa.",
"Options.RefreshRate.Label": "Frecuencia de actualización",
"Options.RefreshRate.Desc": "Frecuencia de actualización de la pantalla completa exclusiva. El modo automático selecciona el modo más cercano.",
"Options.RefreshRate.Automatic": "Automática",
"Options.Scaling.Label": "Escalado",
"Options.Scaling.Desc": "Escala la imagen nativa del sistema invitado sin cambiar su resolución interna.",
"Options.Scaling.Fit": "Ajustar",
"Options.Scaling.Cover": "Cubrir",
"Options.Scaling.Stretch": "Estirar",
"Options.Scaling.Integer": "Entero",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Usa presentación FIFO para evitar el desgarro de imagen.",
"Options.Hdr.Label": "Salida HDR",
"Options.Hdr.Desc": "Usa HDR cuando la pantalla seleccionada y el backend gráfico sean compatibles. El modo automático vuelve a SDR.",
"Options.Hdr.Auto": "Automático"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} jeux",
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": " Ajouter un dossier",
"Library.Rescan": "⟳ Analyser à nouveau",
"Library.AddFolder": "Ajouter un dossier",
"Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Ajouter un dossier de jeux",
"Library.Loading": "Chargement de la bibliothèque…",
"Library.Stat.Version": "Version",
"Library.Stat.Installed": "Installé",
"Library.Stat.TitleId": "ID du titre",
"Common.Back": "Retour",
"Options.General": "Général",
"Options.Logging": "Journalisation",
"Options.Env.Tab": "Environnement",
"Options.Section.Environment": "VARIABLES DENVIRONNEMENT",
"Options.Env.Desc": "Paramètres passés à l’émulateur comme variables denvironnement au lancement.",
@@ -35,6 +39,7 @@
"Options.Env.DumpSpirv.Desc": "Exporter les shaders AGC et leurs traductions SPIR-V dans le dossier shader-dumps.\nÀ utiliser pour signaler des bugs de shader ou de rendu.",
"Options.Env.LogDirectMemory.Desc": "Journaliser les allocations de mémoire directe et les échecs dans la console.\nÀ utiliser quand un jeu plante ou se ferme pendant le démarrage.",
"Options.Env.LogNp.Desc": "Journaliser les appels de la bibliothèque NP (PlayStation Network) dans la console.",
"Options.Env.GuestImageCpuSync.Desc": "Recharger les surfaces invité que le code CPU du jeu réécrit lui-même.\nLaisser désactivé normalement. Activer pour les titres dont les surfaces dessinées par le CPU n'atteignent jamais l'écran.\nCoûte des performances et provoque des régressions sur certains titres, comme GTA V.",
"Options.Section.Emulation": "ÉMULATION",
"Options.Section.Logging": "JOURNALISATION",
"Options.Section.Launcher": "LANCEUR",
@@ -76,6 +81,8 @@
"Options.Language.Label": "Langue de l’émulateur",
"Options.Language.Desc": "Langue utilisée dans lensemble du lanceur. Le changement est immédiat.",
"Options.DefaultProfile.Label": "Nom de profil par défaut",
"Options.DefaultProfile.Desc": "Nom utilisé lorsquun jeu demande une saisie de texte. La valeur par défaut est Sharp.",
"Common.On": "Activé",
"Common.Off": "Désactivé",
@@ -142,17 +149,13 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Rejoignez la communauté, obtenez de laide et suivez le développement.",
"About.GithubButton": "Contribuer sur GitHub !",
"About.DiscordButton": "Rejoindre notre Discord !",
"About.DiscordComingSoon": "Bientôt disponible",
"Library.Context.GameSettings": "Paramètres du jeu…",
"Options.Env.WritableApp0.Desc": "Autoriser les jeux à créer et écrire des fichiers dans leur dossier dinstallation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
"Options.Env.LogIo.Desc": "Journaliser louverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
"Common.Save": "Enregistrer",
"Common.Cancel": "Annuler",
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
"PerGame.EnvToggles.Label": "Variables denvironnement",
"PerGame.EnvToggles.Desc": "Remplacer lensemble global des options SHARPEMU_* pour ce jeu.",
"About.Github.LatestCommitLabel": "Dernier commit",
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
@@ -169,5 +172,34 @@
"Updater.Status.Timeout": "La vérification des mises à jour a expiré après 10 secondes.",
"Updater.Status.Failed": "Impossible de vérifier les mises à jour.",
"Updater.Status.ChecksumFailed": "La mise à jour téléchargée a échoué à la vérification SHA-256.",
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS."
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS.",
"Options.Graphics": "Graphismes",
"Options.Section.Rendering": "RENDU",
"Options.Section.Display": "AFFICHAGE",
"Options.RenderResolution.Label": "Résolution interne",
"Options.RenderResolution.Desc": "Effectuer le rendu des cibles hors écran sous la résolution native et les mettre à l’échelle lors de laffichage. Les valeurs inférieures réduisent la qualité dimage pour libérer des ressources GPU ; prend effet au prochain lancement.",
"Options.RenderResolution.Native": "100 % (native)",
"Options.WindowMode.Label": "Mode fenêtre",
"Options.WindowMode.Desc": "Fenêtre standard, bureau sans bordures ou plein écran exclusif.",
"Options.WindowMode.Windowed": "Fenêtré",
"Options.WindowMode.Borderless": "Sans bordures",
"Options.WindowMode.Exclusive": "Exclusif",
"Options.Resolution.Label": "Résolution",
"Options.Resolution.Desc": "Taille initiale de la fenêtre ou résolution du plein écran exclusif.",
"Options.Display.Label": "Écran",
"Options.Display.Desc": "Moniteur utilisé pour le centrage et le plein écran.",
"Options.RefreshRate.Label": "Fréquence de rafraîchissement",
"Options.RefreshRate.Desc": "Fréquence de rafraîchissement du plein écran exclusif. Le mode automatique sélectionne le mode le plus proche.",
"Options.RefreshRate.Automatic": "Automatique",
"Options.Scaling.Label": "Mise à l’échelle",
"Options.Scaling.Desc": "Mettre à l’échelle limage native du système invité sans modifier sa résolution interne.",
"Options.Scaling.Fit": "Ajuster",
"Options.Scaling.Cover": "Remplir",
"Options.Scaling.Stretch": "Étirer",
"Options.Scaling.Integer": "Entier",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Utiliser la présentation FIFO pour un affichage sans déchirement.",
"Options.Hdr.Label": "Sortie HDR",
"Options.Hdr.Desc": "Utiliser le HDR lorsque l’écran sélectionné et le backend graphique le prennent en charge. Le mode automatique revient au SDR.",
"Options.Hdr.Auto": "Automatique"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} játékok",
"Library.SearchWatermark": "Keresés a könyvtárban",
"Library.AddFolder": " Mappa hozzáadása",
"Library.Rescan": "⟳ Újrakeresés",
"Library.AddFolder": "Mappa hozzáadása",
"Library.OpenFile": "Fájl megnyitása…",
"Library.Context.Launch": "Inditás",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Játékmappa hozzáadása",
"Library.Loading": "Könyvtár betöltése",
"Library.Stat.Version": "Verzió",
"Library.Stat.Installed": "Telepítve",
"Library.Stat.TitleId": "Címazonosító",
"Common.Back": "Vissza",
"Options.General": "Általános",
"Options.Logging": "Logolás",
"Options.Env.Tab": "Környezet",
"Options.Section.Environment": "KÖRNYEZETI VÁLTOZÓK",
"Options.Env.Desc": "Indításkor környezeti változóként az emulátorhoz átadott kapcsolók.",
@@ -35,6 +39,7 @@
"Options.Env.DumpSpirv.Desc": "Az AGC-shaderek és azok SPIR-V-fordításainak mentése a shader-dumps mappába.\nHasználd shader- vagy renderelési hibák jelentésekor.",
"Options.Env.LogDirectMemory.Desc": "A közvetlen memóriaallokációk és hibák naplózása a konzolra.\nHasználd, ha egy játék a rendszerindítás során megszakad vagy kilép.",
"Options.Env.LogNp.Desc": "Az NP (PlayStation Network) könyvtárhívásokat naplózza a konzolra.",
"Options.Env.GuestImageCpuSync.Desc": "Újratölti azokat a vendégfelületeket, amelyeket a játék saját CPU-kódja ír felül.\nNormál esetben hagyd kikapcsolva. Kapcsold be azoknál a címeknél, amelyek CPU-val rajzolt felületei sosem jutnak ki a képernyőre.\nTeljesítménybe kerül, és egyes címeknél, például a GTA V-nél regressziót okoz.",
"Options.Section.Emulation": "EMULÁCIÓ",
"Options.Section.Logging": "LOGOLÁS",
"Options.Section.Launcher": "INDITÓ",
@@ -76,6 +81,8 @@
"Options.Language.Label": "Emulátor nyelve",
"Options.Language.Desc": "Az indítóban használt nyelv. Azonnal érvénybe lép.",
"Options.DefaultProfile.Label": "Alapértelmezett profilnév",
"Options.DefaultProfile.Desc": "A játékok szövegbeviteli kéréseinél használt név. Az alapértelmezett érték Sharp.",
"Common.On": "Be",
"Common.Off": "Ki",
@@ -142,17 +149,13 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
"About.GithubButton": "Járulj hozzá GitHubon!",
"About.DiscordButton": "Csatlakozz a Discordunhoz!",
"About.DiscordComingSoon": "Hamarosan",
"Library.Context.GameSettings": "Játékbeállítások…",
"Options.Env.WritableApp0.Desc": "Engedélyezi, hogy a játékok fájlokat hozzanak létre és írjanak a telepítési mappájukban.\nA kicsomagolt dumpokhoz szükséges, amelyek a mentéseiket vagy beállításaikat az /app0 alá írják.",
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
"Common.Save": "Mentés",
"Common.Cancel": "Mégse",
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
"About.Github.LatestCommitLabel": "Legutóbbi commit",
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
"Updater.Auto.Label": "Frissítések keresése indításkor",
@@ -169,5 +172,34 @@
"Updater.Status.Timeout": "A frissítés-ellenőrzés 10 másodperc után túllépte az időkorlátot.",
"Updater.Status.Failed": "Nem sikerült frissítéseket keresni.",
"Updater.Status.ChecksumFailed": "A letöltött frissítés nem ment át az SHA-256-ellenőrzésen.",
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges."
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges.",
"Options.Graphics": "Grafika",
"Options.Section.Rendering": "RENDERELÉS",
"Options.Section.Display": "KIJELZŐ",
"Options.RenderResolution.Label": "Belső felbontás",
"Options.RenderResolution.Desc": "A képernyőn kívüli célok renderelése a natívnál kisebb felbontáson, majd felskálázás megjelenítéskor. Az alacsonyabb értékek képminőséget cserélnek GPU-tartalékra; a következő indításkor lép érvénybe.",
"Options.RenderResolution.Native": "100% (natív)",
"Options.WindowMode.Label": "Ablakmód",
"Options.WindowMode.Desc": "Normál ablak, keret nélküli asztal vagy kizárólagos teljes képernyő.",
"Options.WindowMode.Windowed": "Ablakos",
"Options.WindowMode.Borderless": "Keret nélküli",
"Options.WindowMode.Exclusive": "Kizárólagos",
"Options.Resolution.Label": "Felbontás",
"Options.Resolution.Desc": "Kezdeti ablakméret vagy kizárólagos teljes képernyős felbontás.",
"Options.Display.Label": "Kijelző",
"Options.Display.Desc": "A középre igazításhoz és teljes képernyőhöz használt monitor.",
"Options.RefreshRate.Label": "Frissítési gyakoriság",
"Options.RefreshRate.Desc": "A kizárólagos teljes képernyő frissítési gyakorisága. Az automatikus mód a legközelebbi módot választja.",
"Options.RefreshRate.Automatic": "Automatikus",
"Options.Scaling.Label": "Méretezés",
"Options.Scaling.Desc": "A natív vendégkép méretezése a belső felbontás módosítása nélkül.",
"Options.Scaling.Fit": "Illesztés",
"Options.Scaling.Cover": "Kitöltés",
"Options.Scaling.Stretch": "Nyújtás",
"Options.Scaling.Integer": "Egész szám",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "FIFO megjelenítés használata képtörésmentes kimenethez.",
"Options.Hdr.Label": "HDR-kimenet",
"Options.Hdr.Desc": "HDR használata, ha a kiválasztott kijelző és grafikus backend támogatja. Az automatikus mód SDR-re vált vissza.",
"Options.Hdr.Auto": "Automatikus"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} giochi",
"Library.SearchWatermark": "Cerca nella libreria…",
"Library.AddFolder": " Aggiungi cartella",
"Library.Rescan": "⟳ Riscansiona",
"Library.AddFolder": "Aggiungi cartella",
"Library.OpenFile": "Apri file…",
"Library.Context.Launch": "Avvia",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Aggiungi cartella giochi",
"Library.Loading": "Caricamento libreria…",
"Library.Stat.Version": "Versione",
"Library.Stat.Installed": "Installato",
"Library.Stat.TitleId": "ID titolo",
"Common.Back": "Indietro",
"Options.General": "Generale",
"Options.Logging": "Log",
"Options.Section.Emulation": "EMULAZIONE",
"Options.Section.Logging": "LOG",
"Options.Section.Launcher": "LAUNCHER",
@@ -70,6 +74,8 @@
"Options.Language.Label": "Lingua dell'emulatore",
"Options.Language.Desc": "Lingua utilizzata in tutto il launcher. Viene applicata immediatamente.",
"Options.DefaultProfile.Label": "Nome profilo predefinito",
"Options.DefaultProfile.Desc": "Nome usato quando un gioco richiede l'inserimento di testo. Il valore predefinito è Sharp.",
"Common.On": "On",
"Common.Off": "Off",
@@ -144,12 +150,9 @@
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
"Options.Env.GuestImageCpuSync.Desc": "Ricarica le superfici guest riscritte dal codice CPU del gioco.\nLasciare disattivato normalmente. Attivare per i titoli le cui superfici disegnate dalla CPU non raggiungono mai lo schermo.\nCosta prestazioni e causa regressioni in alcuni titoli, come GTA V.",
"Common.Save": "Salva",
"Common.Cancel": "Annulla",
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
"PerGame.InheritNote": "Le righe non selezionate ereditano i valori globali.",
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
"Options.About": "Informazioni",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
@@ -158,7 +161,7 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Unisciti alla community, ricevi supporto e segui lo sviluppo.",
"About.GithubButton": "Contribuisci su GitHub!",
"About.DiscordButton": "Unisciti al nostro Discord!",
"About.DiscordComingSoon": "Prossimamente",
"Updater.Auto.Label": "Controlla aggiornamenti all'avvio",
"Updater.Auto.Desc": "Interroga GitHub senza rallentare l'avvio.",
"Updater.Label": "Aggiornamenti",
@@ -173,5 +176,34 @@
"Updater.Status.Timeout": "Il controllo degli aggiornamenti è scaduto dopo 10 secondi.",
"Updater.Status.Failed": "Impossibile controllare gli aggiornamenti.",
"Updater.Status.ChecksumFailed": "L'aggiornamento scaricato non ha superato la verifica SHA-256.",
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS."
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS.",
"Options.Graphics": "Grafica",
"Options.Section.Rendering": "RENDERING",
"Options.Section.Display": "SCHERMO",
"Options.RenderResolution.Label": "Risoluzione interna",
"Options.RenderResolution.Desc": "Renderizza i target fuori schermo sotto la risoluzione nativa e li ridimensiona in fase di presentazione. Valori inferiori sacrificano la qualità dell'immagine per lasciare margine alla GPU; ha effetto al prossimo avvio.",
"Options.RenderResolution.Native": "100% (nativa)",
"Options.WindowMode.Label": "Modalità finestra",
"Options.WindowMode.Desc": "Finestra normale, desktop senza bordi o schermo intero esclusivo.",
"Options.WindowMode.Windowed": "In finestra",
"Options.WindowMode.Borderless": "Senza bordi",
"Options.WindowMode.Exclusive": "Esclusiva",
"Options.Resolution.Label": "Risoluzione",
"Options.Resolution.Desc": "Dimensione iniziale della finestra o risoluzione dello schermo intero esclusivo.",
"Options.Display.Label": "Schermo",
"Options.Display.Desc": "Monitor utilizzato per il centraggio e lo schermo intero.",
"Options.RefreshRate.Label": "Frequenza di aggiornamento",
"Options.RefreshRate.Desc": "Frequenza di aggiornamento dello schermo intero esclusivo. La modalità automatica seleziona la modalità più vicina.",
"Options.RefreshRate.Automatic": "Automatica",
"Options.Scaling.Label": "Ridimensionamento",
"Options.Scaling.Desc": "Ridimensiona l'immagine nativa del sistema guest senza modificarne la risoluzione interna.",
"Options.Scaling.Fit": "Adatta",
"Options.Scaling.Cover": "Riempi",
"Options.Scaling.Stretch": "Estendi",
"Options.Scaling.Integer": "Intero",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Usa la presentazione FIFO per evitare lo screen tearing.",
"Options.Hdr.Label": "Output HDR",
"Options.Hdr.Desc": "Usa HDR quando lo schermo selezionato e il backend grafico lo supportano. La modalità automatica torna a SDR.",
"Options.Hdr.Auto": "Automatico"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "ゲーム {0}本",
"Library.SearchWatermark": "ライブラリを検索…",
"Library.AddFolder": " フォルダーを追加",
"Library.Rescan": "⟳ 再スキャン",
"Library.AddFolder": "フォルダーを追加",
"Library.OpenFile": "ファイルを開く…",
"Library.Context.Launch": "起動",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": "+ ゲームフォルダーを追加",
"Library.Loading": "ライブラリを読み込み中…",
"Library.Stat.Version": "バージョン",
"Library.Stat.Installed": "インストール済み",
"Library.Stat.TitleId": "タイトルID",
"Common.Back": "戻る",
"Options.General": "一般",
"Options.Logging": "ロギング",
"Options.Section.Emulation": "エミュレーション",
"Options.Section.Logging": "ロギング",
"Options.Section.Launcher": "ランチャー",
@@ -67,6 +71,8 @@
"Options.Language.Label": "エミュレータの言語",
"Options.Language.Desc": "ランチャー全体で使用される言語。変更はすぐに適用されます。",
"Options.DefaultProfile.Label": "既定のプロフィール名",
"Options.DefaultProfile.Desc": "ゲームがテキスト入力を要求したときに使用する名前です。既定値は Sharp です。",
"Common.On": "オン",
"Common.Off": "オフ",
@@ -139,12 +145,9 @@
"Options.Env.LogDirectMemory.Desc": "ダイレクトメモリの割り当てと失敗をコンソールに記録します。\nゲームが起動中に中断・終了する場合に使用してください。",
"Options.Env.LogIo.Desc": "ファイルのオープン・読み込み・パス解決の動作をコンソールに記録します。\nゲームが起動中にデータファイルを見つけられない場合に使用してください。",
"Options.Env.LogNp.Desc": "NPPlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
"Options.Env.GuestImageCpuSync.Desc": "ゲーム自身の CPU コードが書き換えるゲスト表面を再アップロードします。\n通常はオフのままにしてください。CPU で描画した表面が画面に反映されないタイトルで有効にします。\n性能を犠牲にし、GTA V など一部のタイトルでは不具合が生じます。",
"Common.Save": "保存",
"Common.Cancel": "キャンセル",
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
"PerGame.EnvToggles.Label": "環境スイッチ",
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
"Options.About": "情報",
"About.Github.Label": "GitHub",
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
@@ -153,7 +156,7 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "コミュニティに参加して、サポートを受けたり開発を追いかけたりしましょう。",
"About.GithubButton": "GitHubで貢献しよう!",
"About.DiscordButton": "Discordに参加しよう!",
"About.DiscordComingSoon": "近日公開",
"Updater.Auto.Label": "起動時にアップデートを確認",
"Updater.Auto.Desc": "起動を遅らせずにGitHubへ確認します。",
"Updater.Label": "アップデート",
@@ -168,5 +171,34 @@
"Updater.Status.Timeout": "アップデートの確認が10秒でタイムアウトしました。",
"Updater.Status.Failed": "アップデートを確認できませんでした。",
"Updater.Status.ChecksumFailed": "ダウンロードしたアップデートはSHA-256検証に失敗しました。",
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。"
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。",
"Options.Graphics": "グラフィックス",
"Options.Section.Rendering": "レンダリング",
"Options.Section.Display": "ディスプレイ",
"Options.RenderResolution.Label": "内部解像度",
"Options.RenderResolution.Desc": "ネイティブ解像度より低い解像度でオフスクリーンターゲットを描画し、表示時にアップスケールします。値を下げると画質と引き換えにGPU負荷を軽減します。次回起動時に適用されます。",
"Options.RenderResolution.Native": "100%(ネイティブ)",
"Options.WindowMode.Label": "ウィンドウモード",
"Options.WindowMode.Desc": "通常ウィンドウ、デスクトップのボーダーレス、または排他フルスクリーン。",
"Options.WindowMode.Windowed": "ウィンドウ",
"Options.WindowMode.Borderless": "ボーダーレス",
"Options.WindowMode.Exclusive": "排他",
"Options.Resolution.Label": "解像度",
"Options.Resolution.Desc": "初期ウィンドウサイズまたは排他フルスクリーンの解像度。",
"Options.Display.Label": "ディスプレイ",
"Options.Display.Desc": "中央配置とフルスクリーンに使用するモニター。",
"Options.RefreshRate.Label": "リフレッシュレート",
"Options.RefreshRate.Desc": "排他フルスクリーンのリフレッシュレート。自動では最も近いモードを選択します。",
"Options.RefreshRate.Automatic": "自動",
"Options.Scaling.Label": "スケーリング",
"Options.Scaling.Desc": "内部解像度を変更せずにゲストのネイティブ画像を拡大縮小します。",
"Options.Scaling.Fit": "フィット",
"Options.Scaling.Cover": "カバー",
"Options.Scaling.Stretch": "引き伸ばし",
"Options.Scaling.Integer": "整数倍",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "ティアリングのない表示のためFIFOプレゼンテーションを使用します。",
"Options.Hdr.Label": "HDR出力",
"Options.Hdr.Desc": "選択したディスプレイとグラフィックスバックエンドが対応している場合にHDRを使用します。自動ではSDRにフォールバックします。",
"Options.Hdr.Auto": "自動"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "게임 {0}개",
"Library.SearchWatermark": "라이브러리 검색…",
"Library.AddFolder": " 폴더 추가",
"Library.Rescan": "⟳ 다시 스캔",
"Library.AddFolder": "폴더 추가",
"Library.OpenFile": "파일 열기…",
"Library.Context.Launch": "실행",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " 게임 폴더 추가",
"Library.Loading": "라이브러리 불러오는 중…",
"Library.Stat.Version": "버전",
"Library.Stat.Installed": "설치됨",
"Library.Stat.TitleId": "타이틀 ID",
"Common.Back": "뒤로",
"Options.General": "일반",
"Options.Logging": "로깅",
"Options.Section.Emulation": "에뮬레이션",
"Options.Section.Logging": "로깅",
"Options.Section.Launcher": "런처",
@@ -67,6 +71,8 @@
"Options.Language.Label": "에뮬레이터 언어",
"Options.Language.Desc": "런처 전체에 사용되는 언어입니다. 변경 사항은 즉시 적용됩니다.",
"Options.DefaultProfile.Label": "기본 프로필 이름",
"Options.DefaultProfile.Desc": "게임에서 텍스트 입력을 요청할 때 사용할 이름입니다. 기본값은 Sharp입니다.",
"Common.On": "켬",
"Common.Off": "끔",
@@ -139,12 +145,9 @@
"Options.Env.LogDirectMemory.Desc": "다이렉트 메모리 할당과 실패를 콘솔에 기록합니다.\n게임이 부팅 중 중단되거나 종료될 때 사용하세요.",
"Options.Env.LogIo.Desc": "파일 열기, 읽기, 경로 확인 동작을 콘솔에 기록합니다.\n게임이 부팅 중 데이터 파일을 찾지 못할 때 사용하세요.",
"Options.Env.LogNp.Desc": "NP(PlayStation Network) 라이브러리 호출을 콘솔에 기록합니다.",
"Options.Env.GuestImageCpuSync.Desc": "게임의 자체 CPU 코드가 다시 쓰는 게스트 표면을 다시 업로드합니다.\n평소에는 꺼 두세요. CPU로 그린 표면이 화면에 나타나지 않는 타이틀에서 켜세요.\n성능을 소모하며 GTA V 등 일부 타이틀에서는 문제가 생깁니다.",
"Common.Save": "저장",
"Common.Cancel": "취소",
"PerGame.Title": "게임별 설정 — {0} ({1})",
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
"PerGame.EnvToggles.Label": "환경 스위치",
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
"Options.About": "정보",
"About.Github.Label": "GitHub",
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
@@ -153,7 +156,7 @@
"About.Discord.Label": "디스코드",
"About.Discord.Desc": "커뮤니티에 참여해 지원을 받고 개발 소식을 확인하세요.",
"About.GithubButton": "GitHub에서 기여하기!",
"About.DiscordButton": "디스코드 참여하기!",
"About.DiscordComingSoon": "곧 공개",
"Updater.Auto.Label": "시작 시 업데이트 확인",
"Updater.Auto.Desc": "시작을 지연시키지 않고 GitHub를 확인합니다.",
"Updater.Label": "업데이트",
@@ -168,5 +171,34 @@
"Updater.Status.Timeout": "업데이트 확인이 10초 후 시간 초과되었습니다.",
"Updater.Status.Failed": "업데이트를 확인할 수 없습니다.",
"Updater.Status.ChecksumFailed": "다운로드한 업데이트가 SHA-256 검증에 실패했습니다.",
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다."
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다.",
"Options.Graphics": "그래픽",
"Options.Section.Rendering": "렌더링",
"Options.Section.Display": "디스플레이",
"Options.RenderResolution.Label": "내부 해상도",
"Options.RenderResolution.Desc": "네이티브 해상도보다 낮은 해상도로 오프스크린 대상을 렌더링한 뒤 표시할 때 업스케일합니다. 값이 낮을수록 화질을 희생해 GPU 여유를 확보하며 다음 실행부터 적용됩니다.",
"Options.RenderResolution.Native": "100% (네이티브)",
"Options.WindowMode.Label": "창 모드",
"Options.WindowMode.Desc": "일반 창, 데스크톱 테두리 없음 또는 독점 전체 화면.",
"Options.WindowMode.Windowed": "창",
"Options.WindowMode.Borderless": "테두리 없음",
"Options.WindowMode.Exclusive": "독점",
"Options.Resolution.Label": "해상도",
"Options.Resolution.Desc": "초기 창 크기 또는 독점 전체 화면 해상도.",
"Options.Display.Label": "디스플레이",
"Options.Display.Desc": "가운데 배치와 전체 화면에 사용할 모니터.",
"Options.RefreshRate.Label": "새로 고침 빈도",
"Options.RefreshRate.Desc": "독점 전체 화면의 새로 고침 빈도입니다. 자동은 가장 가까운 모드를 선택합니다.",
"Options.RefreshRate.Automatic": "자동",
"Options.Scaling.Label": "스케일링",
"Options.Scaling.Desc": "내부 해상도를 변경하지 않고 게스트의 네이티브 이미지를 확대 또는 축소합니다.",
"Options.Scaling.Fit": "맞춤",
"Options.Scaling.Cover": "채우기",
"Options.Scaling.Stretch": "늘이기",
"Options.Scaling.Integer": "정수배",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "티어링 없는 출력을 위해 FIFO 프레젠테이션을 사용합니다.",
"Options.Hdr.Label": "HDR 출력",
"Options.Hdr.Desc": "선택한 디스플레이와 그래픽 백엔드가 지원하는 경우 HDR을 사용합니다. 자동 모드는 SDR로 대체됩니다.",
"Options.Hdr.Auto": "자동"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Zoeken in bibliotheek…",
"Library.AddFolder": " Map toevoegen",
"Library.Rescan": "⟳ Opnieuw scannen",
"Library.AddFolder": "Map toevoegen",
"Library.OpenFile": "Bestand openen…",
"Library.Context.Launch": "Starten",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Gamemap toevoegen",
"Library.Loading": "Bibliotheek laden…",
"Library.Stat.Version": "Versie",
"Library.Stat.Installed": "Geïnstalleerd",
"Library.Stat.TitleId": "Titel-ID",
"Common.Back": "Terug",
"Options.General": "Algemeen",
"Options.Logging": "Logging",
"Options.Section.Emulation": "EMULATIE",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
@@ -67,6 +71,8 @@
"Options.Language.Label": "Taal van de emulator",
"Options.Language.Desc": "Taal die in de hele launcher wordt gebruikt. Wordt direct toegepast.",
"Options.DefaultProfile.Label": "Standaardprofielnaam",
"Options.DefaultProfile.Desc": "Naam die wordt gebruikt wanneer een game om tekstinvoer vraagt. De standaardwaarde is Sharp.",
"Common.On": "Aan",
"Common.Off": "Uit",
@@ -139,12 +145,9 @@
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
"Options.Env.GuestImageCpuSync.Desc": "Gastoppervlakken opnieuw uploaden die de eigen CPU-code van de game herschrijft.\nNormaal uit laten. Inschakelen voor titels waarvan de door de CPU getekende oppervlakken nooit het scherm bereiken.\nKost prestaties en veroorzaakt regressies in sommige titels, zoals GTA V.",
"Common.Save": "Opslaan",
"Common.Cancel": "Annuleren",
"PerGame.Title": "Instellingen per game — {0} ({1})",
"PerGame.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
"Options.About": "Over",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
@@ -153,7 +156,7 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Word lid van de community, krijg ondersteuning en volg de ontwikkeling.",
"About.GithubButton": "Draag bij op GitHub!",
"About.DiscordButton": "Word lid van onze Discord!",
"About.DiscordComingSoon": "Binnenkort",
"Updater.Auto.Label": "Bij het opstarten controleren op updates",
"Updater.Auto.Desc": "Controleert GitHub zonder het opstarten te vertragen.",
"Updater.Label": "Updates",
@@ -168,5 +171,34 @@
"Updater.Status.Timeout": "De updatecontrole is na 10 seconden verlopen.",
"Updater.Status.Failed": "Kon niet controleren op updates.",
"Updater.Status.ChecksumFailed": "De gedownloade update is niet door de SHA-256-verificatie gekomen.",
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS."
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS.",
"Options.Graphics": "Grafisch",
"Options.Section.Rendering": "RENDERING",
"Options.Section.Display": "BEELDSCHERM",
"Options.RenderResolution.Label": "Interne resolutie",
"Options.RenderResolution.Desc": "Render offscreen-doelen onder de oorspronkelijke resolutie en schaal ze bij presentatie op. Lagere waarden ruilen beeldkwaliteit in voor GPU-marge; wordt bij de volgende start toegepast.",
"Options.RenderResolution.Native": "100% (native)",
"Options.WindowMode.Label": "Venstermodus",
"Options.WindowMode.Desc": "Normaal venster, randloos bureaublad of exclusief volledig scherm.",
"Options.WindowMode.Windowed": "Venster",
"Options.WindowMode.Borderless": "Randloos",
"Options.WindowMode.Exclusive": "Exclusief",
"Options.Resolution.Label": "Resolutie",
"Options.Resolution.Desc": "Initiële venstergrootte of resolutie voor exclusief volledig scherm.",
"Options.Display.Label": "Beeldscherm",
"Options.Display.Desc": "Monitor die wordt gebruikt voor centrering en volledig scherm.",
"Options.RefreshRate.Label": "Verversingssnelheid",
"Options.RefreshRate.Desc": "Verversingssnelheid voor exclusief volledig scherm. Automatisch selecteert de dichtstbijzijnde modus.",
"Options.RefreshRate.Automatic": "Automatisch",
"Options.Scaling.Label": "Schaling",
"Options.Scaling.Desc": "Schaal de oorspronkelijke gastafbeelding zonder de interne resolutie te wijzigen.",
"Options.Scaling.Fit": "Passend",
"Options.Scaling.Cover": "Vullend",
"Options.Scaling.Stretch": "Uitrekken",
"Options.Scaling.Integer": "Geheel getal",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Gebruik FIFO-presentatie voor uitvoer zonder tearing.",
"Options.Hdr.Label": "HDR-uitvoer",
"Options.Hdr.Desc": "Gebruik HDR wanneer het geselecteerde beeldscherm en de grafische backend dit ondersteunen. Automatisch valt terug op SDR.",
"Options.Hdr.Auto": "Automatisch"
}
+40 -8
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar biblioteca…",
"Library.AddFolder": " Adicionar pasta",
"Library.Rescan": "⟳ Reanalisar",
"Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir ficheiro…",
"Library.Context.Launch": "Iniciar",
@@ -24,8 +23,13 @@
"Library.Empty.AddFolder": " Adicionar pasta de jogos",
"Library.Loading": "A carregar biblioteca…",
"Library.Stat.Version": "Versão",
"Library.Stat.Installed": "Instalado",
"Library.Stat.TitleId": "ID do título",
"Common.Back": "Voltar",
"Options.General": "Geral",
"Options.Logging": "Registos",
"Options.Env.Tab": "Ambiente",
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
"Options.Env.Desc": "Switches passados ao emulador como variáveis de ambiente no arranque.",
@@ -35,6 +39,7 @@
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e as respetivas traduções SPIR-V para a pasta shader-dumps.\nUtilize ao reportar problemas de shaders ou renderização.",
"Options.Env.LogDirectMemory.Desc": "Regista alocações de memória direta e falhas na consola.\nUtilize quando um jogo aborta ou fecha durante o arranque.",
"Options.Env.LogNp.Desc": "Regista chamadas da biblioteca NP (PlayStation Network) na consola.",
"Options.Env.GuestImageCpuSync.Desc": "Recarrega as superfícies do convidado que o próprio código de CPU do jogo reescreve.\nDeixar desativado normalmente. Ativar para títulos cujas superfícies desenhadas pela CPU nunca chegam ao ecrã.\nCusta desempenho e causa regressões em alguns títulos, como GTA V.",
"Options.Section.Emulation": "EMULAÇÃO",
"Options.Section.Logging": "REGISTOS",
"Options.Section.Launcher": "LANÇADOR",
@@ -76,6 +81,8 @@
"Options.Language.Label": "Idioma do emulador",
"Options.Language.Desc": "Idioma utilizado em todo o lançador. Aplica-se de imediato.",
"Options.DefaultProfile.Label": "Nome de perfil predefinido",
"Options.DefaultProfile.Desc": "Nome utilizado quando um jogo solicita a introdução de texto. O valor predefinido é Sharp.",
"Common.On": "Ativado",
"Common.Off": "Desativado",
@@ -142,17 +149,13 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Junte-se ao nosso Discord!",
"About.DiscordComingSoon": "Em breve",
"Library.Context.GameSettings": "Definições do jogo…",
"Options.Env.WritableApp0.Desc": "Permitir que os jogos criem e escrevam ficheiros dentro da sua pasta de instalação.\nNecessário para dumps não empacotados que escrevem os seus dados guardados ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Definições por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit no ramo main",
"Updater.Auto.Label": "Procurar atualizações no arranque",
@@ -169,5 +172,34 @@
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível procurar atualizações.",
"Updater.Status.ChecksumFailed": "A atualização transferida falhou a verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS.",
"Options.Graphics": "Gráficos",
"Options.Section.Rendering": "RENDERIZAÇÃO",
"Options.Section.Display": "ECRÃ",
"Options.RenderResolution.Label": "Resolução interna",
"Options.RenderResolution.Desc": "Renderiza alvos fora do ecrã abaixo da resolução nativa e amplia-os na apresentação. Valores inferiores trocam qualidade de imagem por margem da GPU; entra em vigor no próximo arranque.",
"Options.RenderResolution.Native": "100% (nativa)",
"Options.WindowMode.Label": "Modo de janela",
"Options.WindowMode.Desc": "Janela normal, ambiente de trabalho sem margens ou ecrã inteiro exclusivo.",
"Options.WindowMode.Windowed": "Em janela",
"Options.WindowMode.Borderless": "Sem margens",
"Options.WindowMode.Exclusive": "Exclusivo",
"Options.Resolution.Label": "Resolução",
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de ecrã inteiro exclusivo.",
"Options.Display.Label": "Ecrã",
"Options.Display.Desc": "Monitor utilizado para centrar e apresentar em ecrã inteiro.",
"Options.RefreshRate.Label": "Taxa de atualização",
"Options.RefreshRate.Desc": "Taxa de atualização do ecrã inteiro exclusivo. O modo automático seleciona o modo mais próximo.",
"Options.RefreshRate.Automatic": "Automática",
"Options.Scaling.Label": "Escala",
"Options.Scaling.Desc": "Dimensiona a imagem nativa do sistema convidado sem alterar a respetiva resolução interna.",
"Options.Scaling.Fit": "Ajustar",
"Options.Scaling.Cover": "Preencher",
"Options.Scaling.Stretch": "Esticar",
"Options.Scaling.Integer": "Inteira",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Utiliza apresentação FIFO para evitar cortes na imagem.",
"Options.Hdr.Label": "Saída HDR",
"Options.Hdr.Desc": "Utiliza HDR quando o ecrã selecionado e o backend gráfico o suportam. O modo automático regressa a SDR.",
"Options.Hdr.Auto": "Automático"
}
+46 -13
View File
@@ -7,8 +7,7 @@
"Page.GameCount.Other": "Игр: {0}",
"Library.SearchWatermark": "Поиск…",
"Library.AddFolder": " Добавить папку",
"Library.Rescan": "⟳ Сканировать",
"Library.AddFolder": "Добавить папку",
"Library.OpenFile": "Открыть файл…",
"Library.Context.Launch": "Запустить",
@@ -25,8 +24,13 @@
"Library.Empty.AddFolder": "+ Добавить папку с играми",
"Library.Loading": "Загрузка библиотеки…",
"Library.Stat.Version": "Версия",
"Library.Stat.Installed": "Установлено",
"Library.Stat.TitleId": "ID игры",
"Common.Back": "Назад",
"Options.General": "Основные",
"Options.Logging": "Логгирование",
"Options.Env.Tab": "Окружение",
"Options.Section.Environment": "ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ",
"Options.Env.Desc": "Параметры, передаваемые эмулятору как переменные окружения при запуске.",
@@ -38,9 +42,40 @@
"Options.Env.LogDirectMemory.Desc": "Выводить в консоль выделения прямой памяти и ошибки выделения.\nИспользуйте, если игра аварийно завершает работу или закрывается при запуске.",
"Options.Env.LogIo.Desc": "Выводить в консоль операции открытия и чтения файлов, а также разрешение путей.\nИспользуйте, если игра не может найти файлы данных при запуске.",
"Options.Env.LogNp.Desc": "Выводить в консоль вызовы библиотеки NP (PlayStation Network).",
"Options.Env.GuestImageCpuSync.Desc": "Повторно загружать гостевые поверхности, которые переписывает собственный код ЦП игры.\nОбычно оставляйте выключенным. Включайте для игр, чьи отрисованные ЦП поверхности не попадают на экран.\nСнижает производительность и вызывает регрессии в некоторых играх, например в GTA V.",
"Options.Section.Emulation": "ЭМУЛЯЦИЯ",
"Options.Section.Logging": "ЛОГГИРОВАНИЕ",
"Options.Section.Launcher": "ЛАУНЧЕР",
"Options.Section.Rendering": "РЕНДЕРИНГ",
"Options.Section.Display": "ЭКРАН",
"Options.Graphics": "Графика",
"Options.RenderResolution.Label": "Внутреннее разрешение",
"Options.RenderResolution.Desc": "Рендерить внеэкранные буферы ниже нативного разрешения и масштабировать при выводе. Меньшие значения снижают качество изображения, но уменьшают нагрузку на GPU; применяется при следующем запуске.",
"Options.RenderResolution.Native": "100% (нативное)",
"Options.WindowMode.Label": "Режим окна",
"Options.WindowMode.Desc": "Обычное окно, безрамочный режим рабочего стола или эксклюзивный полноэкранный режим.",
"Options.WindowMode.Windowed": "Оконный",
"Options.WindowMode.Borderless": "Без рамки",
"Options.WindowMode.Exclusive": "Эксклюзивный",
"Options.Resolution.Label": "Разрешение",
"Options.Resolution.Desc": "Начальный размер окна или разрешение эксклюзивного полноэкранного режима.",
"Options.Display.Label": "Монитор",
"Options.Display.Desc": "Монитор, используемый для центрирования окна и полноэкранного режима.",
"Options.RefreshRate.Label": "Частота обновления",
"Options.RefreshRate.Desc": "Частота обновления эксклюзивного полноэкранного режима. Автоматический режим выбирает ближайшее значение.",
"Options.RefreshRate.Automatic": "Автоматически",
"Options.Scaling.Label": "Масштабирование",
"Options.Scaling.Desc": "Масштабировать нативное изображение игры без изменения внутреннего разрешения.",
"Options.Scaling.Fit": "Вписать",
"Options.Scaling.Cover": "Заполнить",
"Options.Scaling.Stretch": "Растянуть",
"Options.Scaling.Integer": "Целочисленное",
"Options.VSync.Label": "VSync",
"Options.VSync.Desc": "Использовать режим представления FIFO для вывода без разрывов изображения.",
"Options.Hdr.Label": "HDR-вывод",
"Options.Hdr.Desc": "Использовать HDR, если выбранный монитор и графический бэкенд его поддерживают. Автоматический режим при необходимости переключается на SDR.",
"Options.Hdr.Auto": "Авто",
"Options.CpuEngine.Label": "Движок ЦП",
"Options.CpuEngine.Desc": "Движок выполнения, используемый для запуска игрового кода.",
@@ -51,12 +86,12 @@
"Options.LogLevel.Label": "Уровень логгирования",
"Options.LogLevel.Desc": "Подробность вывода в консоль эмулятора.",
"Options.LogLevel.Trace": "Trace",
"Options.LogLevel.Debug": "Debug",
"Options.LogLevel.Info": "Info",
"Options.LogLevel.Warning": "Warning",
"Options.LogLevel.Error": "Error",
"Options.LogLevel.Critical": "Critical",
"Options.LogLevel.Trace": "Трассировка",
"Options.LogLevel.Debug": "Отладка",
"Options.LogLevel.Info": "Информация",
"Options.LogLevel.Warning": "Предупреждение",
"Options.LogLevel.Error": "Ошибка",
"Options.LogLevel.Critical": "Критический",
"Options.TraceImports.Label": "Лимит трассировки импортов",
"Options.TraceImports.Desc": "Трассировать первые N импортов в каждом модуле (0 - выключено).",
@@ -79,16 +114,14 @@
"Options.Language.Label": "Язык эмулятора",
"Options.Language.Desc": "Язык интерфейса лаунчера. Изменение применяется сразу.",
"Options.DefaultProfile.Label": "Имя профиля по умолчанию",
"Options.DefaultProfile.Desc": "Имя, используемое, когда игра запрашивает ввод текста. Значение по умолчанию — Sharp.",
"Common.On": "Включено",
"Common.Off": "Выключено",
"Common.Save": "Сохранить",
"Common.Cancel": "Отмена",
"PerGame.Title": "Настройки игры — {0} ({1})",
"PerGame.InheritNote": "Неотмеченные строки наследуют глобальные настройки.",
"PerGame.EnvToggles.Label": "Переключатели окружения",
"PerGame.EnvToggles.Desc": "Переопределить глобальный набор переключателей SHARPEMU_* для этой игры.",
"Console.Title": "КОНСОЛЬ",
"Console.SearchWatermark": "Поиск...",
@@ -154,7 +187,7 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Присоединяйтесь к сообществу, получайте поддержку и следите за разработкой.",
"About.GithubButton": "Участвовать в разработке на GitHub!",
"About.DiscordButton": "Присоединиться к нашему Discord!",
"About.DiscordComingSoon": "Скоро",
"Updater.Auto.Label": "Проверять обновления при запуске",
"Updater.Auto.Desc": "Проверяет GitHub без задержки запуска.",
+24 -9
View File
@@ -7,9 +7,10 @@
"Page.GameCount.Other": "{0} oyun",
"Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": " Klasör ekle",
"Library.Rescan": "⟳ Yeniden tara",
"Library.AddFolder": "Klasör ekle",
"Library.OpenFile": "Dosya aç…",
"Library.View.Grid": "Alt alta görünüm",
"Library.View.Carousel": "Tek sıra görünüm",
"Library.Context.Launch": "Başlat",
"Library.Context.OpenFolder": "Oyun klasörünü aç",
@@ -24,8 +25,13 @@
"Library.Empty.AddFolder": " Oyun klasörü ekle",
"Library.Loading": "Kütüphane yükleniyor…",
"Library.Stat.Version": "Sürüm",
"Library.Stat.Installed": "Yüklü",
"Library.Stat.TitleId": "Başlık kimliği",
"Common.Back": "Geri",
"Options.General": "Genel",
"Options.Logging": "Günlükleme",
"Options.Section.Emulation": "EMÜLASYON",
"Options.Section.Logging": "GÜNLÜKLEME",
"Options.Section.Launcher": "BAŞLATICI",
@@ -173,14 +179,11 @@
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
"Options.Env.GuestImageCpuSync.Desc": "Oyunun kendi CPU kodunun yeniden yazdığı misafir yüzeyleri tekrar yükler.\nNormalde kapalı bırakın. CPU ile çizilen yüzeyleri ekrana ulaşmayan oyunlarda açın.\nPerformansa mal olur ve GTA V gibi bazı oyunlarda soruna yol açar.",
"Options.DefaultProfile.Label": "Varsayilan profil adi",
"Options.DefaultProfile.Desc": "Oyun metin girisi istediginde kullanilacak ad. Varsayilan deger Sharp'tir.",
"Common.Save": "Kaydet",
"Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
"PerGame.Tab.General": "Genel",
"PerGame.Tab.Graphics": "Grafik",
"PerGame.EnvToggles.Label": "Ortam anahtarları",
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
@@ -189,5 +192,17 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Topluluğa katılın, destek alın ve geliştirmeyi takip edin.",
"About.GithubButton": "GitHub'da katkıda bulun!",
"About.DiscordButton": "Discord'umuza katıl!"
"About.DiscordComingSoon": "Yakında",
"Options.Section.Rendering": "GÖRÜNTÜ İŞLEME",
"Options.RenderResolution.Label": "Dahili çözünürlük",
"Options.RenderResolution.Desc": "Ekran dışı hedefleri doğal çözünürlüğün altında işle ve sunum sırasında ölçeklendir. Daha düşük değerler GPU payı karşılığında görüntü kalitesini azaltır; bir sonraki başlatmada etkili olur.",
"Options.RenderResolution.Native": "%100 (doğal)",
"Options.WindowMode.Windowed": "Pencereli",
"Options.WindowMode.Borderless": "Kenarlıksız",
"Options.WindowMode.Exclusive": "Özel",
"Options.Scaling.Fit": "Sığdır",
"Options.Scaling.Cover": "Kapla",
"Options.Scaling.Stretch": "Uzat",
"Options.Scaling.Integer": "Tam sayı",
"Options.Hdr.Auto": "Otomatik"
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Base type for game cards and actions shown in the library rail.
/// </summary>
public abstract class LibraryTile;
+103
View File
@@ -0,0 +1,103 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
namespace SharpEmu.GUI;
/// <summary>
/// Read-only view of the visible games with one persistent action tile at
/// the end. Game collection changes keep their original indices, so Avalonia
/// can update and virtualize the rail without rebuilding every item.
/// </summary>
public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, INotifyCollectionChanged
{
private readonly ObservableCollection<GameEntry> _games;
public LibraryTileCollection(ObservableCollection<GameEntry> games)
{
_games = games;
_games.CollectionChanged += OnGamesCollectionChanged;
}
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _games.Count + 1;
bool IList.IsFixedSize => false;
bool IList.IsReadOnly => true;
bool ICollection.IsSynchronized => false;
object ICollection.SyncRoot => this;
public LibraryTile this[int index]
{
get
{
if ((uint)index < (uint)_games.Count)
{
return _games[index];
}
if (index == _games.Count)
{
return AddFolderTile.Instance;
}
throw new ArgumentOutOfRangeException(nameof(index));
}
}
object? IList.this[int index]
{
get => this[index];
set => throw new NotSupportedException();
}
public IEnumerator<LibraryTile> GetEnumerator()
{
foreach (var game in _games)
{
yield return game;
}
yield return AddFolderTile.Instance;
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
int IList.Add(object? value) => throw new NotSupportedException();
void IList.Clear() => throw new NotSupportedException();
bool IList.Contains(object? value) => ((IList)this).IndexOf(value) >= 0;
int IList.IndexOf(object? value) => value switch
{
GameEntry game => _games.IndexOf(game),
AddFolderTile => _games.Count,
_ => -1,
};
void IList.Insert(int index, object? value) => throw new NotSupportedException();
void IList.Remove(object? value) => throw new NotSupportedException();
void IList.RemoveAt(int index) => throw new NotSupportedException();
void ICollection.CopyTo(Array array, int index)
{
ArgumentNullException.ThrowIfNull(array);
foreach (var tile in this)
{
array.SetValue(tile, index++);
}
}
private void OnGamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) =>
CollectionChanged?.Invoke(this, args);
}
+58 -82
View File
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
namespace SharpEmu.GUI;
@@ -14,25 +16,50 @@ public sealed record LanguageInfo(string Code, string NativeName);
/// executable overrides the embedded copy for that code, so a translation
/// fix or a brand-new language never needs a rebuild.
/// </summary>
public sealed class Localization
public sealed class Localization : INotifyPropertyChanged
{
public static Localization Instance { get; } = new();
private const string EmbeddedResourcePrefix = "Languages.";
private const string EmbeddedResourceSuffix = ".json";
private const string IndexerPropertyName = "Item";
private readonly string _languagesDirectory;
private Dictionary<string, string> _strings = new();
private Dictionary<string, string> _fallbackStrings = new();
private string _currentCode = "en";
private Localization()
private Localization() : this(LanguagesDirectory)
{
}
internal Localization(string languagesDirectory)
{
_languagesDirectory = languagesDirectory;
}
/// <summary>Directory holding optional *.json language overrides, next to the executable.</summary>
public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages");
public string CurrentCode { get; private set; } = "en";
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>Exposes localized strings to XAML bindings by key.</summary>
public string this[string key] => Get(key);
public string CurrentCode
{
get => _currentCode;
private set
{
if (_currentCode == value)
{
return;
}
_currentCode = value;
OnPropertyChanged();
}
}
public string Get(string key)
{
@@ -67,7 +94,7 @@ public sealed class Localization
try
{
foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json"))
foreach (var file in Directory.EnumerateFiles(_languagesDirectory, "*.json"))
{
var code = Path.GetFileNameWithoutExtension(file);
using var stream = File.OpenRead(file);
@@ -84,47 +111,36 @@ public sealed class Localization
return result;
}
/// <summary>Loads a language by code (e.g. "en"): a loose override file first, then the embedded copy.</summary>
/// english is the fallback language
/// <summary>
/// Loads a language by code. Loose files overlay the embedded language,
/// while English supplies values missing from the selected language.
/// </summary>
public void Load(string code)
{
if (_fallbackStrings.Count == 0 && !string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
_fallbackStrings = LoadMergedLanguage("en");
_strings = string.Equals(code, "en", StringComparison.OrdinalIgnoreCase)
? _fallbackStrings
: LoadMergedLanguage(code);
CurrentCode = code;
OnPropertyChanged(IndexerPropertyName);
}
private Dictionary<string, string> LoadMergedLanguage(string code)
{
var merged = TryLoadEmbedded(code, out var embedded)
? embedded
: new Dictionary<string, string>();
if (TryLoadLooseFile(code, out var loose))
{
if (!TryLoadLooseFile("en", out var fallback) && !TryLoadEmbedded("en", out fallback))
foreach (var (key, value) in loose)
{
fallback = new Dictionary<string, string>();
merged[key] = value;
}
_fallbackStrings = fallback;
}
else if (string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
{
if (TryLoadLooseFile("en", out var enDict) || TryLoadEmbedded("en", out enDict))
{
_strings = enDict;
_fallbackStrings = enDict;
}
else
{
_strings = new Dictionary<string, string>();
_fallbackStrings = new Dictionary<string, string>();
}
CurrentCode = "en";
return;
}
// Load the requested language
if (TryLoadLooseFile(code, out var loaded) || TryLoadEmbedded(code, out loaded))
{
_strings = loaded;
}
else
{
if (_fallbackStrings.Count > 0)
_strings = new Dictionary<string, string>(_fallbackStrings);
else
_strings = new Dictionary<string, string>();
}
CurrentCode = code;
return merged;
}
private static IEnumerable<string> EmbeddedLanguageCodes()
@@ -161,44 +177,12 @@ public sealed class Localization
return null;
}
private bool TryLoadLooseFile(string code)
{
try
{
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
return File.Exists(path) && TryLoad(code, File.ReadAllText(path));
}
catch (Exception)
{
return false;
}
}
private bool TryLoadEmbedded(string code)
{
try
{
using var stream = OpenEmbeddedLanguageStream(code);
if (stream is null)
{
return false;
}
using var reader = new StreamReader(stream);
return TryLoad(code, reader.ReadToEnd());
}
catch (Exception)
{
return false;
}
}
private bool TryLoadLooseFile(string code, out Dictionary<string, string> result)
{
result = new Dictionary<string, string>();
try
{
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
var path = Path.Combine(_languagesDirectory, $"{code}.json");
if (!File.Exists(path))
return false;
@@ -243,14 +227,6 @@ public sealed class Localization
return true;
}
private bool TryLoad(string code, string json)
{
if (TryLoad(json, out var dict))
{
_strings = dict;
CurrentCode = code;
return true;
}
return false;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace SharpEmu.GUI;
/// <summary>
/// A stable settings value with a display label that can be refreshed when
/// the active UI language changes.
/// </summary>
public sealed class LocalizedChoice : INotifyPropertyChanged
{
private string _label;
private LocalizedChoice(string value, string label, string? localizationKey)
{
Value = value;
_label = label;
LocalizationKey = localizationKey;
}
public string Value { get; }
public string Label
{
get => _label;
private set
{
if (_label == value)
{
return;
}
_label = value;
OnPropertyChanged();
}
}
private string? LocalizationKey { get; }
public event PropertyChangedEventHandler? PropertyChanged;
public static LocalizedChoice FromKey(string value, string localizationKey) =>
new(value, localizationKey, localizationKey);
public static LocalizedChoice Literal(string value, string label) =>
new(value, label, null);
public void Refresh(Localization localization)
{
if (LocalizationKey is { } key)
{
Label = localization.Get(key);
}
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
+481
View File
@@ -0,0 +1,481 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.VisualTree;
using SharpEmu.Libs.VideoOut;
namespace SharpEmu.GUI;
/// <summary>Inline per-game settings navigation, loading and persistence.</summary>
public partial class MainWindow
{
private static readonly string[] GameEnvironmentToggleNames =
[
"SHARPEMU_BTHID_UNAVAILABLE",
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
"SHARPEMU_WRITABLE_APP0",
"SHARPEMU_VK_VALIDATION",
"SHARPEMU_DUMP_SPIRV",
"SHARPEMU_LOG_DIRECT_MEMORY",
"SHARPEMU_LOG_IO",
"SHARPEMU_LOG_NP",
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
];
private readonly List<string> _gameEnvironmentPassthrough = new();
private IReadOnlyList<HostDisplayOption> _gameHostDisplays = [];
private bool _isGameSettingsOpen;
private bool _isLoadingGameSettings;
private bool _updatingGameHostDisplayOptions;
private int _gameOptionsIndicatorIndex;
private int _gameOptionsSectionIndex;
private string? _gameSettingsTitleId;
private void WireGameOptions()
{
GameSettingsButton.Click += (_, _) => OpenSelectedGameSettings();
GameLogLevelBox.ItemsSource = _logLevelChoices;
GameWindowModeBox.ItemsSource = _windowModeChoices;
GameScalingModeBox.ItemsSource = _scalingModeChoices;
GameHdrModeBox.ItemsSource = _hdrModeChoices;
var navigationButtons = GameOptionsNavigationButtons();
for (var index = 0; index < navigationButtons.Length; index++)
{
var section = index;
navigationButtons[index].Click += (_, _) =>
{
if (section < GameOptionsSectionPanels().Length)
{
SetGameOptionsSection(section);
}
else
{
CloseGameSettings();
}
};
navigationButtons[index].PointerEntered += (_, _) =>
{
if (_isGameSettingsOpen)
{
SetGameOptionsNavigationIndicator(section);
}
};
navigationButtons[index].GotFocus += (_, _) =>
{
if (_isGameSettingsOpen)
{
SetGameOptionsNavigationIndicator(section);
}
};
}
GameOptionsNavHost.PointerExited += (_, _) =>
{
if (_isGameSettingsOpen)
{
SetGameOptionsNavigationIndicator(_gameOptionsSectionIndex);
}
};
GameOptionsLaunchButton.Click += (_, _) =>
{
CloseGameSettings();
LaunchSelected();
};
GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder();
GameOptionsCopyPathButton.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
GameOptionsCopyTitleIdButton.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
GameOptionsRemoveButton.Click += (_, _) =>
{
CloseGameSettings();
RemoveSelectedFromLibrary();
};
GameStrictToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
GameLogLevelBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameTraceImportsBox.ValueChanged += (_, _) => PersistOpenGameSettings();
GameLogToFileToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
GameWindowModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameDisplayBox.SelectionChanged += (_, _) => OnGameHostDisplayChanged();
GameResolutionBox.SelectionChanged += (_, _) => OnGameHostResolutionChanged();
GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
foreach (var (_, toggle) in GameEnvironmentToggles())
{
toggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
}
SetGameOptionsSection(0, animateIndicator: false);
}
private void OpenSelectedGameSettings()
{
if (GameList.SelectedItem is not GameEntry game)
{
return;
}
if (string.IsNullOrWhiteSpace(game.TitleId))
{
AppendConsoleLine(
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
WarningLineBrush);
return;
}
_gameSettingsTitleId = game.TitleId;
GameOptionsOverlay.DataContext = game;
LoadGameSettings(game.TitleId);
SetGameOptionsSection(0, animateIndicator: false);
GameOptionsLaunchButton.IsEnabled = !_isRunning;
GameOptionsCopyTitleIdButton.IsEnabled =
!string.IsNullOrWhiteSpace(game.TitleId);
_isGameSettingsOpen = true;
SetGameOptionsOpenClass(BackdropLayer, active: true);
SetGameOptionsOpenClass(CarouselHost, active: true);
SetGameOptionsOpenClass(LibrarySelectedDetails, active: true);
SetGameOptionsOpenClass(GameOptionsOverlay, active: true);
GameList.IsHitTestVisible = false;
LibraryToolbar.IsHitTestVisible = false;
GameOptionsOverlay.IsHitTestVisible = true;
GameOptionsGeneralNav.Focus();
}
private void CloseGameSettings(bool restoreLibrary = true)
{
if (!_isGameSettingsOpen)
{
return;
}
_isGameSettingsOpen = false;
SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false);
_gameSettingsTitleId = null;
_gameEnvironmentPassthrough.Clear();
SetGameOptionsOpenClass(BackdropLayer, active: false);
SetGameOptionsOpenClass(CarouselHost, active: false);
SetGameOptionsOpenClass(LibrarySelectedDetails, active: false);
SetGameOptionsOpenClass(GameOptionsOverlay, active: false);
GameOptionsOverlay.IsHitTestVisible = false;
GameOptionsOverlay.DataContext = null;
GameList.IsHitTestVisible = true;
LibraryToolbar.IsHitTestVisible = true;
if (restoreLibrary && _activePageIndex == 0)
{
GameList.Focus();
}
}
private void LoadGameSettings(string titleId)
{
var effective = EffectiveLaunchSettings.Resolve(
_settings,
PerGameSettings.Load(titleId));
_isLoadingGameSettings = true;
_updatingGameHostDisplayOptions = true;
try
{
GameStrictToggle.IsChecked = effective.StrictDynlibResolution;
GameLogLevelBox.SelectedItem = FindChoice(
_logLevelChoices,
effective.LogLevel,
"Info");
GameTraceImportsBox.Value = Math.Clamp(effective.ImportTraceLimit, 0, 4096);
GameLogToFileToggle.IsChecked = effective.LogToFile;
GameWindowModeBox.SelectedItem = FindChoice(
_windowModeChoices,
effective.WindowMode,
"Windowed");
GameScalingModeBox.SelectedItem = FindChoice(
_scalingModeChoices,
effective.ScalingMode,
"Fit");
GameVSyncToggle.IsChecked = effective.VSync;
GameHdrModeBox.SelectedItem = FindChoice(
_hdrModeChoices,
effective.HdrMode,
"Auto");
_gameHostDisplays = HostDisplayOptions.BuildDisplays(
HostDisplayCatalog.Query(),
effective.DisplayIndex);
GameDisplayBox.ItemsSource = _gameHostDisplays;
var display = HostDisplayOptions.SelectDisplay(
_gameHostDisplays,
effective.DisplayIndex);
GameDisplayBox.SelectedItem = display;
PopulateGameHostModes(
display,
effective.Resolution,
effective.RefreshRate);
_gameEnvironmentPassthrough.Clear();
foreach (var entry in effective.EnvironmentToggles)
{
if (!IsKnownGameEnvironmentEntry(entry))
{
_gameEnvironmentPassthrough.Add(entry);
}
}
foreach (var (name, toggle) in GameEnvironmentToggles())
{
toggle.IsChecked = IsEnvironmentEnabled(
effective.EnvironmentToggles,
name);
}
}
finally
{
_updatingGameHostDisplayOptions = false;
_isLoadingGameSettings = false;
}
}
private void PersistOpenGameSettings()
{
if (!_isGameSettingsOpen ||
_isLoadingGameSettings ||
_updatingGameHostDisplayOptions ||
string.IsNullOrWhiteSpace(_gameSettingsTitleId))
{
return;
}
var settings = new PerGameSettings
{
LogLevel = SelectedComboText(GameLogLevelBox, "Info"),
ImportTraceLimit = (int)(GameTraceImportsBox.Value ?? 0),
StrictDynlibResolution = GameStrictToggle.IsChecked == true,
LogToFile = GameLogToFileToggle.IsChecked == true,
WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"),
Resolution = SelectedComboText(GameResolutionBox, "1920x1080"),
DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display
? display.Index
: 0,
RefreshRate = SelectedGameRefreshRate(),
ScalingMode = SelectedComboText(GameScalingModeBox, "Fit"),
VSync = GameVSyncToggle.IsChecked == true,
HdrMode = SelectedComboText(GameHdrModeBox, "Auto"),
EnvironmentToggles = BuildGameEnvironmentEntries(),
};
settings.RemoveInheritedValues(_settings);
settings.Save(_gameSettingsTitleId);
}
private void OnGameHostDisplayChanged()
{
if (_isLoadingGameSettings ||
_updatingGameHostDisplayOptions ||
GameDisplayBox.SelectedItem is not HostDisplayOption display)
{
return;
}
_updatingGameHostDisplayOptions = true;
try
{
PopulateGameHostModes(
display,
GameResolutionBox.SelectedItem as string ?? "1920x1080",
SelectedGameRefreshRate());
}
finally
{
_updatingGameHostDisplayOptions = false;
}
PersistOpenGameSettings();
}
private void OnGameHostResolutionChanged()
{
if (_isLoadingGameSettings ||
_updatingGameHostDisplayOptions ||
GameDisplayBox.SelectedItem is not HostDisplayOption display)
{
return;
}
_updatingGameHostDisplayOptions = true;
try
{
PopulateGameRefreshRates(
display,
GameResolutionBox.SelectedItem as string,
SelectedGameRefreshRate());
}
finally
{
_updatingGameHostDisplayOptions = false;
}
PersistOpenGameSettings();
}
private void PopulateGameHostModes(
HostDisplayOption display,
string selectedResolution,
int selectedRefreshRate)
{
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
GameResolutionBox.ItemsSource = resolutions;
GameResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase))
?? resolutions[0];
PopulateGameRefreshRates(
display,
GameResolutionBox.SelectedItem as string,
selectedRefreshRate);
}
private void PopulateGameRefreshRates(
HostDisplayOption display,
string? resolution,
int selectedRefreshRate)
{
var refreshRates = HostDisplayOptions.BuildRefreshRates(
display,
resolution,
selectedRefreshRate,
Localization.Instance.Get("Options.RefreshRate.Automatic"));
GameRefreshRateBox.ItemsSource = refreshRates;
GameRefreshRateBox.SelectedItem = refreshRates.FirstOrDefault(
refreshRate => refreshRate.Value == selectedRefreshRate) ?? refreshRates[0];
}
private int SelectedGameRefreshRate() =>
GameRefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
? refreshRate.Value
: 0;
private List<string> BuildGameEnvironmentEntries()
{
var entries = new List<string>(_gameEnvironmentPassthrough);
foreach (var (name, toggle) in GameEnvironmentToggles())
{
if (toggle.IsChecked == true)
{
entries.Add(name);
}
}
return entries;
}
private static LocalizedChoice FindChoice(
IEnumerable<LocalizedChoice> choices,
string value,
string fallback) =>
choices.FirstOrDefault(choice =>
string.Equals(choice.Value, value, StringComparison.OrdinalIgnoreCase))
?? choices.First(choice =>
string.Equals(choice.Value, fallback, StringComparison.OrdinalIgnoreCase));
private static bool IsEnvironmentEnabled(
IEnumerable<string> entries,
string name)
{
foreach (var entry in entries)
{
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
if (!string.Equals(parts[0], name, StringComparison.OrdinalIgnoreCase))
{
continue;
}
return parts.Length == 1 || parts[1] != "0";
}
return false;
}
private static bool IsKnownGameEnvironmentEntry(string entry)
{
var name = entry.Split('=', 2, StringSplitOptions.TrimEntries)[0];
return GameEnvironmentToggleNames.Contains(name, StringComparer.OrdinalIgnoreCase);
}
private void SetGameOptionsSection(int section, bool animateIndicator = true)
{
var buttons = GameOptionsSectionButtons();
var panels = GameOptionsSectionPanels();
section = Math.Clamp(section, 0, buttons.Length - 1);
_gameOptionsSectionIndex = section;
SetGameOptionsNavigationIndicator(section, animateIndicator);
for (var index = 0; index < buttons.Length; index++)
{
var active = index == section;
SetActiveClass(buttons[index], active);
SetOptionsPanelInteraction(panels[index], active);
}
SetActiveClass(GameOptionsBackNav, active: false);
}
private void SetGameOptionsNavigationIndicator(int section, bool animate = true)
{
var buttons = GameOptionsNavigationButtons();
_gameOptionsIndicatorIndex = Math.Clamp(section, 0, buttons.Length - 1);
var button = buttons[_gameOptionsIndicatorIndex];
MoveNavigationIndicator(
GameOptionsNavIndicator,
GameOptionsNavHost,
button,
_gameOptionsIndicatorIndex,
animate);
}
private Button[] GameOptionsNavigationButtons() =>
[
GameOptionsGeneralNav,
GameOptionsLoggingNav,
GameOptionsRenderingNav,
GameOptionsEnvironmentNav,
GameOptionsBackNav,
];
private Button[] GameOptionsSectionButtons() =>
[
GameOptionsGeneralNav,
GameOptionsLoggingNav,
GameOptionsRenderingNav,
GameOptionsEnvironmentNav,
];
private Control[] GameOptionsSectionPanels() =>
[
GameOptionsGeneralPanel,
GameOptionsLoggingPanel,
GameOptionsRenderingPanel,
GameOptionsEnvironmentPanel,
];
private (string Name, ToggleSwitch Toggle)[] GameEnvironmentToggles() =>
[
("SHARPEMU_BTHID_UNAVAILABLE", GameEnvBthidToggle),
("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", GameEnvLoopGuardToggle),
("SHARPEMU_WRITABLE_APP0", GameEnvWritableApp0Toggle),
("SHARPEMU_VK_VALIDATION", GameEnvVkValidationToggle),
("SHARPEMU_DUMP_SPIRV", GameEnvDumpSpirvToggle),
("SHARPEMU_LOG_DIRECT_MEMORY", GameEnvLogDirectMemoryToggle),
("SHARPEMU_LOG_IO", GameEnvLogIoToggle),
("SHARPEMU_LOG_NP", GameEnvLogNpToggle),
("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle),
];
private static void SetGameOptionsOpenClass(Control control, bool active) =>
SetClass(control, "gameOptionsOpen", active);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+97
View File
@@ -92,6 +92,74 @@ public sealed class PerGameSettings
return settings;
}
/// <summary>
/// Removes values that already match the global configuration so the
/// remaining object contains only effective per-game overrides.
/// </summary>
internal void RemoveInheritedValues(GuiSettings global)
{
if (string.Equals(LogLevel, global.LogLevel, StringComparison.OrdinalIgnoreCase))
{
LogLevel = null;
}
if (ImportTraceLimit == global.ImportTraceLimit)
{
ImportTraceLimit = null;
}
if (StrictDynlibResolution == global.StrictDynlibResolution)
{
StrictDynlibResolution = null;
}
if (LogToFile == global.LogToFile)
{
LogToFile = null;
}
if (string.Equals(WindowMode, global.WindowMode, StringComparison.OrdinalIgnoreCase))
{
WindowMode = null;
}
if (string.Equals(Resolution, global.Resolution, StringComparison.OrdinalIgnoreCase))
{
Resolution = null;
}
if (DisplayIndex == global.DisplayIndex)
{
DisplayIndex = null;
}
if (RefreshRate == global.RefreshRate)
{
RefreshRate = null;
}
if (string.Equals(ScalingMode, global.ScalingMode, StringComparison.OrdinalIgnoreCase))
{
ScalingMode = null;
}
if (VSync == global.VSync)
{
VSync = null;
}
if (string.Equals(HdrMode, global.HdrMode, StringComparison.OrdinalIgnoreCase))
{
HdrMode = null;
}
if (EnvironmentToggles is { } environmentToggles &&
EnvironmentEntriesEqual(environmentToggles, global.EnvironmentToggles))
{
EnvironmentToggles = null;
}
}
public void Save(string titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
@@ -130,6 +198,35 @@ public sealed class PerGameSettings
return trimmed.Length == 0 ? "UNKNOWN" : trimmed;
}
private static bool EnvironmentEntriesEqual(
IEnumerable<string> left,
IEnumerable<string> right) =>
NormalizeEnvironmentEntries(left).SetEquals(NormalizeEnvironmentEntries(right));
private static HashSet<string> NormalizeEnvironmentEntries(IEnumerable<string> entries)
{
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
if (parts.Length == 0 || string.IsNullOrWhiteSpace(parts[0]))
{
continue;
}
if (parts.Length == 2 && parts[1] == "0")
{
continue;
}
normalized.Add(parts.Length == 2 && parts[1] != "1"
? $"{parts[0]}={parts[1]}"
: parts[0]);
}
return normalized;
}
}
public sealed record EffectiveLaunchSettings(
-426
View File
@@ -1,426 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using SharpEmu.Libs.VideoOut;
namespace SharpEmu.GUI;
public sealed class PerGameSettingsDialog : Window
{
private static readonly string[] LogLevels =
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
private static readonly string[] HdrModes = { "Auto", "On", "Off" };
private static readonly string[] EnvToggles =
{
"SHARPEMU_BTHID_UNAVAILABLE",
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
"SHARPEMU_WRITABLE_APP0",
"SHARPEMU_VK_VALIDATION",
"SHARPEMU_DUMP_SPIRV",
"SHARPEMU_LOG_DIRECT_MEMORY",
"SHARPEMU_LOG_IO",
"SHARPEMU_LOG_NP",
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
};
private readonly string _titleId;
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
private bool _updatingHostDisplayOptions;
private readonly SettingRow _logLevelRow;
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
private readonly SettingRow _traceRow;
private readonly NumericUpDown _trace = new()
{
Minimum = 0, Maximum = 4096, Increment = 16, Width = 160, FormatString = "0",
};
private readonly SettingRow _strictRow;
private readonly ToggleSwitch _strict = new();
private readonly SettingRow _logToFileRow;
private readonly ToggleSwitch _logToFile = new();
private readonly SettingRow _windowModeRow;
private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
private readonly SettingRow _resolutionRow;
private readonly ComboBox _resolution = new() { Width = 160 };
private readonly SettingRow _displayIndexRow;
private readonly ComboBox _displayIndex = new() { Width = 240 };
private readonly SettingRow _refreshRateRow;
private readonly ComboBox _refreshRate = new() { Width = 160 };
private readonly SettingRow _scalingModeRow;
private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
private readonly SettingRow _vsyncRow;
private readonly ToggleSwitch _vsync = new();
private readonly SettingRow _hdrModeRow;
private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
private readonly SettingRow _envRow;
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
public PerGameSettingsDialog(string titleId, string displayName, GuiSettings global)
{
_titleId = titleId;
var loc = Localization.Instance;
Title = loc.Format("PerGame.Title", displayName, titleId);
Width = 520;
MaxHeight = 720;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
CanResize = false;
Background = new SolidColorBrush(Color.Parse("#0D1017"));
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
_windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
_resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
_displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
_refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
_scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
_vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
_hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
_envRow = new SettingRow
{
Label = loc.Get("PerGame.EnvToggles.Label"),
Description = loc.Get("PerGame.EnvToggles.Desc"),
ShowOverride = true,
};
foreach (var name in EnvToggles)
{
var box = new ToggleSwitch { OnContent = name, OffContent = name };
_envBoxes.Add((name, box));
_envList.Children.Add(box);
}
var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
graphics.Children.Add(Card(
loc.Get("Options.Section.Display"),
_windowModeRow,
_resolutionRow,
_displayIndexRow,
_refreshRateRow,
_scalingModeRow,
_vsyncRow,
_hdrModeRow));
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
content.Children.Add(new TextBlock
{
Text = loc.Get("PerGame.InheritNote"),
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
FontSize = 12,
});
content.Children.Add(new TabControl
{
ItemsSource = new[]
{
new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
},
});
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
save.Click += (_, _) => { Persist(); Close(); };
cancel.Click += (_, _) => Close();
var buttonBar = new Border
{
BorderBrush = new SolidColorBrush(Color.Parse("#8B94A7")) { Opacity = 0.25 },
BorderThickness = new Thickness(0, 1, 0, 0),
Padding = new(16),
Child = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, save },
},
};
var root = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
var scroller = new ScrollViewer { Content = content };
Grid.SetRow(scroller, 0);
Grid.SetRow(buttonBar, 1);
root.Children.Add(scroller);
root.Children.Add(buttonBar);
Content = root;
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
LoadValues(global);
_envRow.PropertyChanged += (_, e) =>
{
if (e.Property == SettingRow.IsOverriddenProperty)
{
_envList.IsEnabled = _envRow.IsOverridden;
}
};
_envList.IsEnabled = _envRow.IsOverridden;
}
private static SettingRow Row(string label, string description, Control value) => new()
{
Label = label,
Description = description,
ShowOverride = true,
Content = value,
};
private static Border Card(string title, params Control[] rows)
{
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 14 };
stack.Children.Add(new TextBlock { Text = title, Classes = { "sectionTitle" } });
foreach (var row in rows)
{
stack.Children.Add(row);
}
var card = new Border { Child = stack };
card.Classes.Add("card");
return card;
}
private void LoadValues(GuiSettings global)
{
var existing = PerGameSettings.Load(_titleId);
var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
var resolution = existing?.Resolution ?? global.Resolution;
var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
_updatingHostDisplayOptions = true;
try
{
_hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
_displayIndex.ItemsSource = _hostDisplays;
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
_displayIndex.SelectedItem = display;
PopulateHostModes(display, resolution, refreshRate);
}
finally
{
_updatingHostDisplayOptions = false;
}
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
_trace.Value = global.ImportTraceLimit;
_strict.IsChecked = global.StrictDynlibResolution;
_logToFile.IsChecked = global.LogToFile;
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
_vsync.IsChecked = global.VSync;
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: false);
}
if (existing is null)
{
return;
}
if (existing.LogLevel is { } level && Array.IndexOf(LogLevels, level) >= 0)
{
_logLevelRow.IsOverridden = true;
_logLevel.SelectedItem = level;
}
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
{
_windowModeRow.IsOverridden = true;
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
}
if (existing.Resolution is not null)
{
_resolutionRow.IsOverridden = true;
}
if (existing.DisplayIndex is not null)
{
_displayIndexRow.IsOverridden = true;
}
if (existing.RefreshRate is not null)
{
_refreshRateRow.IsOverridden = true;
}
if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
{
_scalingModeRow.IsOverridden = true;
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
}
if (existing.VSync is { } vsync)
{
_vsyncRow.IsOverridden = true;
_vsync.IsChecked = vsync;
}
if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
{
_hdrModeRow.IsOverridden = true;
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
}
if (existing.EnvironmentToggles is { } env)
{
_envRow.IsOverridden = true;
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: false);
}
}
}
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
private void OnHostDisplayChanged()
{
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
{
return;
}
_updatingHostDisplayOptions = true;
try
{
PopulateHostModes(
display,
_resolution.SelectedItem as string ?? "1920x1080",
SelectedRefreshRate());
}
finally
{
_updatingHostDisplayOptions = false;
}
}
private void OnHostResolutionChanged()
{
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
{
return;
}
var selectedRefreshRate = SelectedRefreshRate();
_updatingHostDisplayOptions = true;
try
{
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
}
finally
{
_updatingHostDisplayOptions = false;
}
}
private void PopulateHostModes(
HostDisplayOption display,
string selectedResolution,
int selectedRefreshRate)
{
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
_resolution.ItemsSource = resolutions;
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
}
private void PopulateRefreshRates(
HostDisplayOption display,
string? resolution,
int selectedRefreshRate)
{
var rates = HostDisplayOptions.BuildRefreshRates(
display,
resolution,
selectedRefreshRate,
Localization.Instance.Get("Options.RefreshRate.Automatic"));
_refreshRate.ItemsSource = rates;
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
}
private int SelectedRefreshRate() =>
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
private void Persist()
{
var settings = new PerGameSettings
{
LogLevel = _logLevelRow.IsOverridden ? _logLevel.SelectedItem as string : null,
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
? display.Index
: null,
RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
EnvironmentToggles = _envRow.IsOverridden ? BuildEnvironmentEntries() : null,
};
settings.Save(_titleId);
}
private List<string> BuildEnvironmentEntries()
{
return _envBoxes
.Where(entry => entry.Box.IsChecked == true)
.Select(entry => entry.Name)
.ToList();
}
private static bool IsEnvironmentEnabled(
IEnumerable<string> entries,
string name,
bool defaultValue)
{
foreach (var entry in entries)
{
if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return defaultValue;
}
}
+1 -37
View File
@@ -3,9 +3,7 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Media;
namespace SharpEmu.GUI;
@@ -18,17 +16,9 @@ public sealed class SettingRow : ContentControl
public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
public static readonly StyledProperty<bool> ShowOverrideProperty =
AvaloniaProperty.Register<SettingRow, bool>(nameof(ShowOverride));
public static readonly StyledProperty<bool> IsOverriddenProperty =
AvaloniaProperty.Register<SettingRow, bool>(
nameof(IsOverridden), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<FontFamily?> LabelFontFamilyProperty =
AvaloniaProperty.Register<SettingRow, FontFamily?>(nameof(LabelFontFamily));
private ContentPresenter? _slot;
private TextBlock? _label;
public string? Label
@@ -43,18 +33,6 @@ public sealed class SettingRow : ContentControl
set => SetValue(DescriptionProperty, value);
}
public bool ShowOverride
{
get => GetValue(ShowOverrideProperty);
set => SetValue(ShowOverrideProperty, value);
}
public bool IsOverridden
{
get => GetValue(IsOverriddenProperty);
set => SetValue(IsOverriddenProperty, value);
}
public FontFamily? LabelFontFamily
{
get => GetValue(LabelFontFamilyProperty);
@@ -64,20 +42,14 @@ public sealed class SettingRow : ContentControl
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_slot = e.NameScope.Find<ContentPresenter>("PART_Slot");
_label = e.NameScope.Find<TextBlock>("PART_Label");
UpdateSlotEnabled();
UpdateLabelFont();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ShowOverrideProperty || change.Property == IsOverriddenProperty)
{
UpdateSlotEnabled();
}
else if (change.Property == LabelFontFamilyProperty)
if (change.Property == LabelFontFamilyProperty)
{
UpdateLabelFont();
}
@@ -90,12 +62,4 @@ public sealed class SettingRow : ContentControl
_label.FontFamily = family;
}
}
private void UpdateSlotEnabled()
{
if (_slot is not null)
{
_slot.IsEnabled = !ShowOverride || IsOverridden;
}
}
}
+9
View File
@@ -36,6 +36,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Fonts\MaterialSymbolsRounded-400.ttf" />
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
<AvaloniaResource Include="..\..\assets\images\github.png" Link="Assets/github.png" />
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
@@ -44,6 +45,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\LICENSES\Apache-2.0.txt"
Link="licenses\Apache-2.0.txt"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
TargetPath="licenses\Apache-2.0.txt" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Languages\*.json">
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
+15
View File
@@ -11,6 +11,21 @@ Window and text defaults shared by all launcher views.
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="TextBlock.materialSymbol">
<Setter Property="FontFamily" Value="{StaticResource MaterialSymbolsRounded}" />
<Setter Property="FontFeatures" Value="+liga" />
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="LineHeight" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.materialSymbol.compact">
<Setter Property="FontSize" Value="16" />
<Setter Property="LineHeight" Value="18" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -43,6 +43,23 @@ Shared launcher button variants and page switcher styles.
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.iconGhost">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.iconGhost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
@@ -0,0 +1,36 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Window chrome button sizing and interaction states.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Button.windowChrome">
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="44" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.windowChromeGlyph">
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
<Style Selector="TextBlock.windowCloseGlyph">
<Setter Property="Margin" Value="0,-1,0,1" />
</Style>
<Style Selector="Button.windowChrome:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.windowClose:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
</Styles>
@@ -0,0 +1,186 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Contextual per-game options reveal, actions, and selected-game motion.
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="TextBlock.gameStatLabel">
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Opacity" Value="0.75" />
</Style>
<Style Selector="TextBlock.gameStatValue">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Panel.backdropLayer">
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
</Style>
<Style Selector="Panel.carouselHost">
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost">
<Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="Grid.gameOptionsOverlay">
<Setter Property="RenderTransform" Value="translateY(96px)" />
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="Panel.backdropLayer.gameOptionsOpen">
<Setter Property="RenderTransform" Value="translateY(-92px) scale(1.04)" />
</Style>
<Style Selector="Panel.carouselHost.gameOptionsOpen">
<Setter Property="RenderTransform" Value="translateY(-18px) scale(1)" />
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gameOptionsOpen">
<Setter Property="RenderTransform" Value="translateY(-28px)" />
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="Grid.gameOptionsOverlay.gameOptionsOpen">
<Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="Button.playButton">
<Setter Property="MinHeight" Value="54" />
<Setter Property="MinWidth" Value="200" />
<Setter Property="Padding" Value="34,13" />
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Background" Value="White" />
<Setter Property="Foreground" Value="#111111" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.playButton:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.playButton.danger">
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.playButton.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.optionsCircle, ToggleButton.optionsCircle">
<Setter Property="Width" Value="54" />
<Setter Property="Height" Value="54" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="27" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
ToggleButton.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
ToggleButton.optionsCircle:checked /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#24FFFFFF" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost">
<Setter Property="Spacing" Value="18" />
</Style>
<Style Selector="TextBlock.selectedGameTitle">
<Setter Property="FontSize" Value="42" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout">
<Setter Property="Spacing" Value="12" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout TextBlock.selectedGameTitle">
<Setter Property="FontSize" Value="26" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.playButton">
<Setter Property="MinHeight" Value="44" />
<Setter Property="MinWidth" Value="168" />
<Setter Property="Padding" Value="28,10" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.optionsCircle,
StackPanel.selectedDetailsHost.gridLayout ToggleButton.optionsCircle">
<Setter Property="Width" Value="44" />
<Setter Property="Height" Value="44" />
<Setter Property="CornerRadius" Value="22" />
</Style>
<Style Selector="Button.gameOptionsLaunch">
<Setter Property="Width" Value="220" />
<Setter Property="Height" Value="46" />
<Setter Property="Padding" Value="20,0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Background" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#111111" />
<Setter Property="FontSize" Value="15" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.gameOptionsLaunch /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BoxShadow" Value="0 0 0 0 Transparent" />
</Style>
<Style Selector="Button.gameOptionsLaunch:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#EAF3FF" />
<Setter Property="Foreground" Value="#111111" />
</Style>
<Style Selector="Button.gameAction">
<Setter Property="Width" Value="250" />
<Setter Property="Height" Value="48" />
<Setter Property="Padding" Value="16,0" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="#0FFFFFFF" />
<Setter Property="BorderBrush" Value="#14FFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="Medium" />
</Style>
<Style Selector="Button.gameAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#1AFFFFFF" />
<Setter Property="BorderBrush" Value="#28FFFFFF" />
</Style>
<Style Selector="Button.gameAction.dangerAction">
<Setter Property="Foreground" Value="{StaticResource DangerBrush}" />
<Setter Property="BorderBrush" Value="#48FF646E" />
</Style>
<Style Selector="Button.gameAction.dangerAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#1FFF646E" />
<Setter Property="BorderBrush" Value="{StaticResource DangerBrush}" />
</Style>
</Styles>
+141 -16
View File
@@ -6,32 +6,157 @@ Cover-art library item states and motion.
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Padding" Value="10" />
<Setter Property="Margin" Value="5" />
<Setter Property="CornerRadius" Value="14" />
<Style Selector="ListBox.tileGrid">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4,0,28,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid.gridLayout">
<Setter Property="Padding" Value="4,0,14,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Width" Value="160" />
<Setter Property="Height" Value="172" />
<Setter Property="Padding" Value="4" />
<Setter Property="Margin" Value="0,8,12,8" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Opacity" Value="0.72" />
<Setter Property="RenderTransformOrigin" Value="50%,50%" />
<Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
<DoubleTransition Property="Opacity"
Duration="0:0:0.11"
Easing="QuadraticEaseOut" />
<TransformOperationsTransition Property="RenderTransform"
Duration="0:0:0.16"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
<Setter Property="RenderTransform" Value="translateY(-3px)" />
<!-- The title rides along in the item template so both layouts share one
template. The rail hides it because the selected game already names
itself in the details below the covers. -->
<Style Selector="TextBlock.libraryTileName">
<Setter Property="IsVisible" Value="False" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LineHeight" Value="16" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
<Style Selector="ListBox.tileGrid.gridLayout TextBlock.libraryTileName">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Style Selector="ListBox.tileGrid.gridLayout ListBoxItem">
<Setter Property="Height" Value="200" />
<Setter Property="Margin" Value="0,6,12,10" />
<Setter Property="VerticalContentAlignment" Value="Top" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
ListBox.tileGrid ListBoxItem:selected">
<Setter Property="Opacity" Value="1" />
<Setter Property="RenderTransform" Value="translateY(-5px) scale(1.035)" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem /template/ ContentPresenter#PART_ContentPresenter,
ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter,
ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Border.libraryGameCard">
<Setter Property="CornerRadius" Value="7" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BoxShadow" Value="0 12 28 0 #61000000" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="BorderBrush"
Duration="0:0:0.12"
Easing="QuadraticEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected Border.libraryGameCard">
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
</Style>
<Style Selector="Border.libraryGameCard Border.coverClip">
<Setter Property="CornerRadius" Value="5" />
</Style>
<Style Selector="Border.addFolderTile">
<Setter Property="CornerRadius" Value="7" />
<Setter Property="Background" Value="#0CFFFFFF" />
<Setter Property="BorderBrush" Value="#2EFFFFFF" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BoxShadow" Value="0 12 28 0 #3A000000" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<BrushTransition Property="BorderBrush"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.addFolderTileIcon">
<Setter Property="Width" Value="60" />
<Setter Property="Height" Value="60" />
<Setter Property="CornerRadius" Value="30" />
<Setter Property="Background" Value="#1BFFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.addFolderGlyph">
<Setter Property="Foreground" Value="#A6FFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.addFolderIconGlyph">
<Setter Property="RenderTransform" Value="translateY(-7px)" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover TextBlock.addFolderGlyph,
ListBox.tileGrid ListBoxItem:selected TextBlock.addFolderGlyph">
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTile,
ListBox.tileGrid ListBoxItem:selected Border.addFolderTile">
<Setter Property="Background" Value="#1AFFFFFF" />
<Setter Property="BorderBrush" Value="#55FFFFFF" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTileIcon,
ListBox.tileGrid ListBoxItem:selected Border.addFolderTileIcon">
<Setter Property="Background" Value="#2EFFFFFF" />
</Style>
</Styles>
@@ -0,0 +1,128 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Options page section transitions and content layout
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
<Style Selector="Border.optionsContentSurface">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<Style Selector="ScrollViewer.optionsSectionPanel">
<Setter Property="Opacity" Value="0" />
<Setter Property="RenderTransform" Value="translateY(22px)" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<TransformOperationsTransition Property="RenderTransform"
Duration="0:0:0.28"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="ScrollViewer.optionsSectionPanel.active">
<Setter Property="Opacity" Value="1" />
<Setter Property="RenderTransform" Value="translateY(0px)" />
</Style>
<Style Selector="Border.optionsGroup">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="Border.optionsInfoRow">
<Setter Property="MinHeight" Value="70" />
<Setter Property="Padding" Value="18,12" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="#06FFFFFF" />
<Setter Property="BorderBrush" Value="#0AFFFFFF" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="ComboBox.optionValue, settingsControls|SplitNumericUpDown.optionValue, TextBox.optionValue">
<Setter Property="Width" Value="190" />
<Setter Property="MinHeight" Value="44" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="TextBox.optionValue">
<Setter Property="Padding" Value="17,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="TextAlignment" Value="Left" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
</Style>
<Style Selector="TextBox.optionValue /template/ Border#PART_BorderElement">
<Setter Property="CornerRadius" Value="14" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<!-- The outer split-input surface owns hover and focus animation -->
<Style Selector="TextBox.splitNumericTextBox /template/ Border#PART_BorderElement,
TextBox.splitNumericTextBox:pointerover /template/ Border#PART_BorderElement,
TextBox.splitNumericTextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="ToggleSwitch.optionToggle">
<Setter Property="Theme" Value="{StaticResource OptionToggleTheme}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.optionAction">
<Setter Property="MinWidth" Value="156" />
<Setter Property="MinHeight" Value="44" />
<Setter Property="Padding" Value="18,0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
</Style>
<Style Selector="Button.optionAction /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button.optionAction:pointerover /template/ ContentPresenter#PART_ContentPresenter,
Button.optionAction:focus-visible /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#20FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
</Styles>
@@ -0,0 +1,100 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Shared navigation for global and per-game options
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Border.optionsNavSurface">
<Setter Property="Width" Value="244" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="VerticalAlignment" Value="Top" />
</Style>
<Style Selector="Border.optionsNavIndicator">
<Setter Property="Height" Value="54" />
<Setter Property="Margin" Value="0,3,0,0" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="#13FFFFFF" />
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="BoxShadow" Value="0 8 24 0 #24000000" />
</Style>
<Style Selector="Button.optionsNav">
<Setter Property="Height" Value="61" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Padding" Value="16,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="RenderTransform" Value="none" />
</Style>
<Style Selector="Button.optionsNav /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
</Style>
<Style Selector="Button.optionsNav:pointerover /template/ ContentPresenter#PART_ContentPresenter,
Button.optionsNav:focus-visible /template/ ContentPresenter#PART_ContentPresenter,
Button.optionsNav.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.optionsNav:pressed">
<Setter Property="RenderTransform" Value="none" />
</Style>
<Style Selector="TextBlock.optionsNavIcon">
<Setter Property="Width" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Padding" Value="0,0,0,5" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.optionsNavLabel">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="LineHeight" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Padding" Value="0,0,0,4" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavIcon,
Button.optionsNav:focus-visible TextBlock.optionsNavIcon,
Button.optionsNav.active TextBlock.optionsNavIcon,
Button.optionsNav:pointerover TextBlock.optionsNavLabel,
Button.optionsNav:focus-visible TextBlock.optionsNavLabel,
Button.optionsNav.active TextBlock.optionsNavLabel">
<Setter Property="Foreground" Value="White" />
</Style>
</Styles>
@@ -0,0 +1,76 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Options-only control resources: compact toggle theme
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ControlTheme x:Key="OptionToggleTheme" TargetType="ToggleSwitch">
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="26" />
<Setter Property="MinWidth" Value="46" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="#2BFFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="KnobTransitions">
<Transitions>
<DoubleTransition Property="Canvas.Left"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="PART_Track"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Padding="3"
Background="{TemplateBinding Background}"
BorderThickness="0"
CornerRadius="13"
ClipToBounds="True">
<Canvas x:Name="PART_SwitchKnob"
Width="20"
Height="20"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<Grid x:Name="PART_MovingKnobs"
Width="20"
Height="20"
Canvas.Left="0">
<Border x:Name="PART_Thumb"
Background="{StaticResource TextBrush}"
BorderThickness="0"
CornerRadius="10" />
</Grid>
</Canvas>
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.16"
Easing="CubicEaseOut" />
</Transitions>
</Border.Transitions>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#PART_Track">
<Setter Property="Background" Value="#38FFFFFF" />
</Style>
<Style Selector="^:checked /template/ Border#PART_Track">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="^:checked:pointerover /template/ Border#PART_Track">
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
</Style>
<Style Selector="^:disabled /template/ Border#PART_Track">
<Setter Property="Opacity" Value="0.45" />
</Style>
</ControlTheme>
</ResourceDictionary>
@@ -8,26 +8,51 @@ Control theme for the shared launcher settings row.
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI">
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
<Setter Property="MinHeight" Value="70" />
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<ToggleSwitch OnContent="Override" OffContent="Override" MinWidth="0"
VerticalAlignment="Center"
IsVisible="{TemplateBinding ShowOverride}"
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
<ContentPresenter x:Name="PART_Slot"
<Border x:Name="PART_RowSurface"
Classes="settingRowSurface"
Background="#06FFFFFF"
BorderBrush="#0AFFFFFF"
BorderThickness="1"
CornerRadius="14"
Padding="18,12">
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<BrushTransition Property="BorderBrush"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Border.Transitions>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="3" Margin="0,0,18,0">
<TextBlock x:Name="PART_Label"
Text="{TemplateBinding Label}"
FontSize="14"
FontWeight="Medium"
TextWrapping="Wrap"
Foreground="{StaticResource TextBrush}" />
<TextBlock Text="{TemplateBinding Description}"
FontSize="11"
FontWeight="Normal"
Foreground="{StaticResource SettingsDescriptionBrush}"
LineHeight="16"
MaxWidth="690"
HorizontalAlignment="Left"
TextAlignment="Left"
TextWrapping="Wrap"
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<ContentPresenter Grid.Column="1"
Content="{TemplateBinding Content}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</Grid>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
@@ -0,0 +1,197 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Control themes for SplitNumericUpDown and its spinner parts
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
<ControlTheme x:Key="SplitNumericRepeatButtonTheme" TargetType="RepeatButton">
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#B8FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter x:Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
Content="{TemplateBinding Content}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}">
<ContentPresenter.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</ContentPresenter.Transitions>
</ContentPresenter>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#16FFFFFF" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="^:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#22FFFFFF" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="^:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Opacity" Value="0.45" />
</Style>
</ControlTheme>
<ControlTheme x:Key="SplitNumericSpinnerTheme" TargetType="ButtonSpinner">
<Setter Property="MinHeight" Value="44" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#E8FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Focusable" Value="True" />
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,52"
ColumnSpacing="8">
<Border x:Name="PART_ValueSurface"
MinHeight="{TemplateBinding MinHeight}"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
CornerRadius="14"
ClipToBounds="True">
<ContentPresenter x:Name="PART_ContentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Border.Transitions>
</Border>
<Border x:Name="PART_SpinnerSurface"
Grid.Column="1"
MinHeight="{TemplateBinding MinHeight}"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
CornerRadius="14"
ClipToBounds="True"
IsVisible="{TemplateBinding ShowButtonSpinner}">
<Grid RowDefinitions="*,*">
<RepeatButton x:Name="PART_IncreaseButton"
Grid.Row="0"
Theme="{StaticResource SplitNumericRepeatButtonTheme}"
IsTabStop="{TemplateBinding IsTabStop}">
<TextBlock Classes="materialSymbol compact"
Text="keyboard_arrow_up" />
</RepeatButton>
<RepeatButton x:Name="PART_DecreaseButton"
Grid.Row="1"
Theme="{StaticResource SplitNumericRepeatButtonTheme}"
IsTabStop="{TemplateBinding IsTabStop}">
<TextBlock Classes="materialSymbol compact"
Text="keyboard_arrow_down" />
</RepeatButton>
<Border Height="1"
Grid.RowSpan="2"
Background="#14FFFFFF"
VerticalAlignment="Center"
IsHitTestVisible="False" />
</Grid>
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Border.Transitions>
</Border>
</Grid>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#PART_ValueSurface,
^:focus-within /template/ Border#PART_ValueSurface">
<Setter Property="Background" Value="#20FFFFFF" />
</Style>
<Style Selector="^:pointerover /template/ Border#PART_SpinnerSurface,
^:focus-within /template/ Border#PART_SpinnerSurface">
<Setter Property="Background" Value="#20FFFFFF" />
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type settingsControls:SplitNumericUpDown}"
TargetType="settingsControls:SplitNumericUpDown">
<Setter Property="MinHeight" Value="44" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#E8FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Left" />
<Setter Property="Template">
<ControlTemplate>
<DataValidationErrors>
<ButtonSpinner x:Name="PART_Spinner"
Theme="{StaticResource SplitNumericSpinnerTheme}"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
IsTabStop="False"
Padding="0"
MinWidth="0"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
AllowSpin="{TemplateBinding AllowSpin}"
ShowButtonSpinner="{TemplateBinding ShowButtonSpinner}">
<TextBox x:Name="PART_TextBox"
Classes="splitNumericTextBox"
MinHeight="{TemplateBinding MinHeight}"
MinWidth="0"
Margin="0"
Padding="17,0"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Foreground="{TemplateBinding Foreground}"
PlaceholderText="{TemplateBinding PlaceholderText}"
PlaceholderForeground="{TemplateBinding PlaceholderForeground}"
IsReadOnly="{TemplateBinding IsReadOnly}"
VerticalContentAlignment="Center"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
Text="{TemplateBinding Text}"
TextAlignment="{TemplateBinding TextAlignment}"
AcceptsReturn="False"
TextWrapping="NoWrap"
InnerLeftContent="{Binding InnerLeftContent, RelativeSource={RelativeSource TemplatedParent}}"
InnerRightContent="{Binding InnerRightContent, RelativeSource={RelativeSource TemplatedParent}}" />
</ButtonSpinner>
</DataValidationErrors>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
+19 -19
View File
@@ -6,27 +6,27 @@ Shared colors and brushes used throughout the launcher.
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<FontFamily x:Key="MaterialSymbolsRounded">avares://SharpEmu.GUI/Assets/Fonts/MaterialSymbolsRounded-400.ttf#Material Symbols Rounded</FontFamily>
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
<GradientStop Offset="0" Color="#12151F" />
<GradientStop Offset="0.55" Color="#0D1017" />
<GradientStop Offset="1" Color="#0B0D14" />
</LinearGradientBrush>
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
<SolidColorBrush x:Key="BgBrush" Color="#0C0C0C" />
<SolidColorBrush x:Key="ChromeBrush" Color="#070707" />
<SolidColorBrush x:Key="CardBrush" Color="#161616" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#12FFFFFF" />
<SolidColorBrush x:Key="ElevatedBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="GameOptionsMenuBrush" Color="#1B1B1B" />
<SolidColorBrush x:Key="TextBrush" Color="#F7F8FB" />
<SolidColorBrush x:Key="SecondaryTextBrush" Color="#C0C5CF" />
<SolidColorBrush x:Key="MutedBrush" Color="#777F8E" />
<SolidColorBrush x:Key="SettingsDescriptionBrush" Color="#8A8A8A" />
<SolidColorBrush x:Key="FaintBrush" Color="#4D5461" />
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
<SolidColorBrush x:Key="DangerBrush" Color="#FF646E" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#FF858D" />
<SolidColorBrush x:Key="SuccessBrush" Color="#66F2A3" />
<SolidColorBrush x:Key="InfoBrush" Color="#64A7FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#0BFFFFFF" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#12FFFFFF" />
</ResourceDictionary>
@@ -0,0 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
internal readonly record struct WindowMaximizeButtonState(
string Glyph,
string ToolTip,
string AutomationName);
+555 -55
View File
@@ -3,7 +3,7 @@
using System.Collections.Concurrent;
using SharpEmu.HLE;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.Libs.Kernel;
@@ -46,6 +46,9 @@ public static partial class AgcExports
private const uint ItNumInstances = 0x2F;
private const uint ItDrawIndexMultiAuto = 0x30;
private const uint ItDrawIndexOffset2 = 0x35;
private const uint ItDrawIndexIndirectMulti = 0x38;
private const uint DrawIndexedIndirectArgsSize = 20;
private const uint DrawIndexedIndirectMaxScan = 1024;
private const uint ItWriteData = 0x37;
private const uint ItDispatchDirect = 0x15;
private const uint ItDispatchIndirect = 0x16;
@@ -319,6 +322,7 @@ public static partial class AgcExports
private static int _tracedVertexRangeCount;
private static long _dcbWaitRegMemTraceCount;
private static long _createShaderTraceCount;
private static long _duplicateTargetTraceCount;
private static long _cbMetadataSkipTraceCount;
private static long _packetPayloadTraceCount;
private static bool _tracedMissingPixelShaderBindings;
@@ -326,6 +330,11 @@ public static partial class AgcExports
private static long _labelProducerSequence;
private static readonly object _labelProducerGate = new();
private static readonly List<LabelProducerTrace> _labelProducers = [];
private const int LabelProducerSoftBound = 4096;
// Raised when a compaction pass frees nothing because every record is still
// active, so registration does not rescan the whole list on every add while
// a queue is suspended. Reset once compaction can make progress again.
private static int _labelProducerCompactionBound = LabelProducerSoftBound;
private static readonly HashSet<(object Memory, ulong Address)>
_tracedProducerlessWaits = new();
private static long _shaderTranslationMissTraceCount;
@@ -600,10 +609,20 @@ public static partial class AgcExports
public uint DrawIndexOffset { get; set; }
public bool PredicateSkip { get; set; }
public string QueueName { get; set; } = "graphics";
// Ident this queue's end-of-pipe completion interrupt is published under.
// The graphics queue keeps 0; a compute queue takes the owner handle it
// was submitted with, which is the same value the guest registers through
// sceAgcDriverAddEqEvent.
public ulong CompletionEventId { get; set; }
public ulong ActiveSubmissionId { get; set; }
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
public bool HasActiveSubmission { get; set; }
public bool IsSuspended { get; set; }
// Set when parsing stops on an INDIRECT_BUFFER packet so the caller can
// continue into the buffer it links to.
public ulong PendingChainAddress { get; set; }
public uint PendingChainDwords { get; set; }
public ulong CompletionEventNotifiedSubmissionId { get; set; }
public Dictionary<(uint Op, uint Register), uint> FramePacketCounts { get; } = new();
public uint FramePacketCount { get; set; }
@@ -1994,6 +2013,65 @@ public static partial class AgcExports
return ReturnPointer(ctx, drawCommand);
}
[SysAbiExport(
Nid = "1q1titRBL6o",
ExportName = "sceAgcDcbDrawIndirect",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbDrawIndirect(CpuContext ctx)
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
var dataOffset = (uint)ctx[CpuRegister.Rsi];
var emit = Interlocked.Increment(ref _indirectDrawEmitCount);
if (emit <= 12 || emit % 250 == 0)
{
var rcx = ctx[CpuRegister.Rcx];
var dump = string.Empty;
for (var word = 0; word < 8; word++)
{
dump += TryReadUInt32(ctx, rcx + dataOffset + ((ulong)word * 4), out var raw)
? $" {raw}"
: " ?";
}
Console.Error.WriteLine(
$"[LOADER][WARN] agc.emit_indirect#{emit} buf=0x{commandBufferAddress:X16} " +
$"off=0x{dataOffset:X} rdx=0x{ctx[CpuRegister.Rdx]:X} rcx=0x{rcx:X} " +
$"r8=0x{ctx[CpuRegister.R8]:X} rcx_words:{dump}");
}
if (commandBufferAddress == 0)
{
Interlocked.Increment(ref _indirectDrawEmitRejectCount);
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var drawCommand) ||
!TryWriteUInt32(ctx, drawCommand, Pm4(5, ItDrawIndirect, 0)) ||
!TryWriteUInt32(ctx, drawCommand + 4, dataOffset) ||
!TryWriteUInt32(ctx, drawCommand + 8, 0) ||
!TryWriteUInt32(ctx, drawCommand + 12, 0) ||
!TryWriteUInt32(ctx, drawCommand + 16, 0))
{
var rejects = Interlocked.Increment(ref _indirectDrawEmitRejectCount);
if (rejects <= 8 || rejects % 250 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] agc.emit_indirect_reject#{rejects} " +
$"buf=0x{commandBufferAddress:X16} reason=alloc_or_write");
}
return ReturnPointer(ctx, 0);
}
TraceAgc(
$"agc.dcb_draw_indirect buf=0x{commandBufferAddress:X16} " +
$"draw=0x{drawCommand:X16} offset=0x{dataOffset:X}");
return ReturnPointer(ctx, drawCommand);
}
[SysAbiExport(
Nid = "Yw0jKSqop+E",
ExportName = "sceAgcDcbDrawIndexAuto",
@@ -2052,6 +2130,39 @@ public static partial class AgcExports
return ReturnPointer(ctx, commandAddress);
}
[SysAbiExport(
Nid = "ypVBz4uPKcQ",
ExportName = "sceAgcDcbDrawIndexIndirectMulti",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbDrawIndexIndirectMulti(CpuContext ctx)
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
var dataOffset = (uint)ctx[CpuRegister.Rsi];
var drawCount = (uint)ctx[CpuRegister.Rdx];
var stride = DrawIndexedIndirectArgsSize;
var modifier = (uint)ctx[CpuRegister.R8];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 8, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(8, ItDrawIndexIndirectMulti, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, dataOffset) ||
!TryWriteUInt32(ctx, commandAddress + 8, 0) ||
!TryWriteUInt32(ctx, commandAddress + 12, 0) ||
!TryWriteUInt32(ctx, commandAddress + 16, 0) ||
!TryWriteUInt32(ctx, commandAddress + 20, drawCount) ||
!TryWriteUInt32(ctx, commandAddress + 24, stride) ||
!TryWriteUInt32(ctx, commandAddress + 28, modifier))
{
return ReturnPointer(ctx, 0);
}
TraceAgc(
$"agc.dcb_draw_index_indirect_multi buf=0x{commandBufferAddress:X16} " +
$"cmd=0x{commandAddress:X16} offset=0x{dataOffset:X8} draws={drawCount} " +
$"stride={stride} modifier=0x{modifier:X8}");
return ReturnPointer(ctx, commandAddress);
}
[SysAbiExport(
Nid = "mStuvI0zOtc",
ExportName = "sceAgcDcbDrawIndexIndirectGetSize",
@@ -3154,6 +3265,7 @@ public static partial class AgcExports
!TryReadUInt64(ctx, packetAddress, out var commandAddress) ||
!TryReadUInt32(ctx, packetAddress + 8, out var dwordCount))
{
TraceAgc($"agc.driver_submit_dcb_rejected packet=0x{packetAddress:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
@@ -3166,10 +3278,9 @@ public static partial class AgcExports
}
}
if (tracePackets)
{
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
}
TraceAgc(
$"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} " +
$"dwords={dwordCount} end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}");
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
@@ -3216,12 +3327,10 @@ public static partial class AgcExports
}
}
if (tracePackets)
{
TraceAgc(
$"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " +
$"addr=0x{commandAddress:X16} dwords={dwordCount}");
}
TraceAgc(
$"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " +
$"addr=0x{commandAddress:X16} dwords={dwordCount} " +
$"end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}");
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
@@ -3234,6 +3343,7 @@ public static partial class AgcExports
}
queueState.QueueName = $"acb.compute[{ownerHandle}]";
queueState.CompletionEventId = ownerHandle;
EnqueueSubmittedDcb(
ctx,
gpuState,
@@ -3424,33 +3534,47 @@ public static partial class AgcExports
SubmittedDcbState state,
ulong submissionId)
{
if (!ReferenceEquals(state, gpuState.Graphics) ||
state.CompletionEventNotifiedSubmissionId == submissionId)
if (state.CompletionEventNotifiedSubmissionId == submissionId)
{
return;
}
state.CompletionEventNotifiedSubmissionId = submissionId;
// Hardware raises an end-of-pipe interrupt for every submission on every
// queue, so this is unconditional. It stays safe for titles that do not
// want it because delivery is registration-gated: TriggerRegisteredEvents
// only queues onto equeues that registered this exact ident through
// sceAgcDriverAddEqEvent. Graphics keeps ident 0; a compute queue uses the
// owner handle it was submitted under.
var completionEventId = state.CompletionEventId;
var isGraphics = ReferenceEquals(state, gpuState.Graphics);
var queueName = state.QueueName;
void TriggerCompletionEvents()
{
var triggered = KernelEventQueueCompatExports.TriggerRegisteredEvents(
ident: 0,
completionEventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
data: 0);
if (_compatibilitySubmitCompletionEvent)
completionEventId);
// The broad fan-out wakes graphics registrations whose ident never
// matches anything the driver publishes. That is a compatibility
// guess rather than hardware behavior, so it stays opt-in and stays
// on the graphics queue where it was measured.
if (isGraphics && _compatibilitySubmitCompletionEvent)
{
triggered += KernelEventQueueCompatExports.TriggerRegisteredEventsDistinct(
KernelEventQueueCompatExports.KernelEventFilterGraphics);
}
TraceAgc(
$"agc.driver_submit_dcb completion submission={submissionId} " +
$"queues={triggered}");
$"agc.completion_event queue={queueName} submission={submissionId} " +
$"event=0x{completionEventId:X} queues={triggered}");
}
// A DCB is complete only after its translated Vulkan work and ordered
// guest-memory writes have finished. Put the notification on that same
// logical graphics queue instead of approximating completion with a
// timer, which can wake Unity while its upload data is still stale.
// A submission is complete only after its translated Vulkan work and
// ordered guest-memory writes have finished. Put the notification on that
// same logical queue instead of approximating completion with a timer or a
// ThreadPool hop, either of which can only make the interrupt late and
// reorder it against registration changes (and can wake Unity while its
// upload data is still stale).
if (GuestGpu.Current.SubmitOrderedGuestAction(
TriggerCompletionEvents,
$"agc submit completion {submissionId}") == 0)
@@ -3478,33 +3602,72 @@ public static partial class AgcExports
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue(
state.QueueName,
state.ActiveSubmissionId);
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
var rented = GuestDataPool.Shared.Rent(windowByteCount);
try
// A submission is one link of a chain, not necessarily the whole stream:
// when a title's command arena fills mid-frame it continues in a fresh
// buffer and links the two with an INDIRECT_BUFFER packet, then submits
// only the first link. Stopping at the end of the submitted window drops
// every packet past the switch -- including the flip and the end-of-frame
// completion labels the guest is waiting on.
for (var chainDepth = 0; ; chainDepth++)
{
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
if (chainDepth > MaxSubmittedChainDepth)
{
_dcbWindowBuffer = rented;
_dcbWindowStart = commandAddress;
_dcbWindowByteLength = windowByteCount;
TraceAgc(
$"agc.dcb_chain_depth_exceeded queue={state.QueueName} " +
$"submission={state.ActiveSubmissionId} addr=0x{commandAddress:X16}");
return false;
}
return ParseSubmittedDcbCore(
ctx,
gpuState,
state,
commandAddress,
dwordCount,
tracePackets);
}
finally
{
_dcbWindowBuffer = null;
_dcbWindowByteLength = 0;
GuestDataPool.Shared.Return(rented);
state.PendingChainAddress = 0;
state.PendingChainDwords = 0;
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
var rented = GuestDataPool.Shared.Rent(windowByteCount);
bool suspended;
try
{
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
{
_dcbWindowBuffer = rented;
_dcbWindowStart = commandAddress;
_dcbWindowByteLength = windowByteCount;
}
suspended = ParseSubmittedDcbCore(
ctx,
gpuState,
state,
commandAddress,
dwordCount,
tracePackets);
}
finally
{
_dcbWindowBuffer = null;
_dcbWindowByteLength = 0;
GuestDataPool.Shared.Return(rented);
}
if (suspended)
{
return true;
}
var chainAddress = state.PendingChainAddress;
var chainDwords = state.PendingChainDwords;
if (chainAddress == 0 || chainDwords == 0 || chainDwords > 1_000_000)
{
return false;
}
commandAddress = chainAddress;
dwordCount = chainDwords;
}
}
// Deep enough for a title that links one continuation buffer per frame,
// shallow enough that a self-referencing chain cannot spin forever.
private const int MaxSubmittedChainDepth = 64;
private static bool ParseSubmittedDcbCore(
CpuContext ctx,
SubmittedGpuState gpuState,
@@ -3631,6 +3794,32 @@ public static partial class AgcExports
continue;
}
if (op == ItIndirectBuffer &&
length >= 4 &&
TryReadUInt32(ctx, currentAddress + 4, out var chainLow) &&
TryReadUInt32(ctx, currentAddress + 8, out var chainHigh) &&
TryReadUInt32(ctx, currentAddress + 12, out var chainDwords))
{
var chainAddress = ((ulong)(chainHigh & 0xFFFFu) << 32) | chainLow;
var chainLength = chainDwords & 0xFFFFFu;
// Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they
// decided not to take. Only a populated one redirects the stream.
if (chainAddress != 0 && chainLength != 0)
{
state.PendingChainAddress = chainAddress;
state.PendingChainDwords = chainLength;
TraceAgc(
$"agc.dcb_chain queue={state.QueueName} " +
$"submission={state.ActiveSubmissionId} " +
$"packet=0x{currentAddress:X16} " +
$"target=0x{chainAddress:X16} dwords={chainLength}");
// The link is a jump, not a call: whatever follows it in this
// buffer is unreachable padding.
return false;
}
}
if (op == ItNop &&
register is RDrawReset or RAcbReset &&
length >= 2)
@@ -3812,6 +4001,7 @@ public static partial class AgcExports
if (TryReadSubmittedDrawCount(
ctx,
gpuState,
state,
currentAddress,
length,
@@ -3835,7 +4025,8 @@ public static partial class AgcExports
var indexed = op is
ItDrawIndex2 or
ItDrawIndexOffset2 or
ItDrawIndexIndirect;
ItDrawIndexIndirect or
ItDrawIndexIndirectMulti;
state.SawIndexedDraw |= indexed;
TryTranslateGuestDraw(ctx, gpuState, state, indexCount, indexed);
}
@@ -4195,6 +4386,7 @@ public static partial class AgcExports
op is ItDispatchDirect or ItDispatchIndirect ||
op is ItDrawIndirect or
ItDrawIndexIndirect or
ItDrawIndexIndirectMulti or
ItDrawIndex2 or
ItDrawIndexAuto or
ItDrawIndexMultiAuto or
@@ -4299,9 +4491,21 @@ public static partial class AgcExports
};
lock (_labelProducerGate)
{
if (_labelProducers.Count >= 4096)
if (_labelProducers.Count >= _labelProducerCompactionBound)
{
_labelProducers.RemoveRange(0, 1024);
// Active producer records are synchronization state, not a
// diagnostic cache. Removing one can hide an earlier
// same-submission label write and make a valid in-stream fence
// suspend forever. Compact only completed history; if all
// records are active, correctness takes precedence over the
// soft diagnostic bound.
var removed = CompactCompletedEntries(
_labelProducers,
static candidate => candidate.Completed,
targetCount: LabelProducerSoftBound * 3 / 4);
_labelProducerCompactionBound = removed == 0
? _labelProducers.Count * 2
: LabelProducerSoftBound;
}
_labelProducers.Add(producer);
@@ -4322,6 +4526,36 @@ public static partial class AgcExports
return producer;
}
internal static int CompactCompletedEntries<T>(
List<T> entries,
Func<T, bool> isCompleted,
int targetCount)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(isCompleted);
targetCount = Math.Max(0, targetCount);
// Single order-preserving pass. Removing one-by-one would shift the
// tail on every eviction, which is quadratic on a list this size and
// runs while the label gate is held.
var removable = entries.Count - targetCount;
var removed = 0;
var write = 0;
for (var read = 0; read < entries.Count; read++)
{
if (removed < removable && isCompleted(entries[read]))
{
removed++;
continue;
}
entries[write++] = entries[read];
}
entries.RemoveRange(write, entries.Count - write);
return removed;
}
private static void CompleteLabelProducer(LabelProducerTrace? producer)
{
if (producer is null)
@@ -5982,6 +6216,13 @@ public static partial class AgcExports
GpuWaitRegistry.RecordProduced(
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
}
else if (!wroteData && dataSelection is 1 or 2)
{
// See ApplySubmittedReleaseMem: a dropped label write strands
// every waiter on this label permanently.
ReportLabelWriteFailure(
"release_mem_standard", destinationAddress, data, dataSelection);
}
if (tracePacket)
{
@@ -5997,6 +6238,33 @@ public static partial class AgcExports
writesGuestMemory ? writeLength : 0);
}
private static long _labelWriteFailureCount;
/// <summary>
/// Reports a GPU release-label write that could not reach guest memory.
/// Rate-limited (first 16, then powers of two) because a wedged queue can
/// retry, but never silenced: this is the difference between a diagnosable
/// fault and a permanently suspended graphics queue with no explanation.
/// </summary>
private static void ReportLabelWriteFailure(
string packet,
ulong destinationAddress,
ulong data,
uint dataSelection)
{
var count = Interlocked.Increment(ref _labelWriteFailureCount);
if (count > 16 && (count & (count - 1)) != 0)
{
return;
}
Console.Error.WriteLine(
$"[LOADER][ERROR] agc.label_write_failed packet={packet} " +
$"dst=0x{destinationAddress:X16} data=0x{data:X16} " +
$"data_sel={dataSelection} count={count} — a suspended WAIT_REG_MEM " +
$"on this label can no longer be satisfied or deadlock-broken.");
}
private static (uint Destination, uint DataSelection)
DecodeStandardReleaseMemControl(uint control) =>
(
@@ -6057,6 +6325,15 @@ public static partial class AgcExports
GpuWaitRegistry.RecordProduced(
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
}
else if (!wroteData && dataSelection is 1 or 2)
{
// A label write that fails is not a benign miss: this packet
// is the producer a suspended WAIT_REG_MEM is waiting for, and
// RecordProduced above is skipped, so the deadlock breaker has
// no value to replay either. The queue then never resumes.
// Never let that happen quietly.
ReportLabelWriteFailure("release_mem", destinationAddress, data, dataSelection);
}
if (tracePacket)
{
@@ -6164,8 +6441,24 @@ public static partial class AgcExports
}
}
private const int IndirectArgsFlushTimeoutMilliseconds = 250;
private static void FlushGpuWorkForIndirectArgs(SubmittedGpuState gpuState)
{
var pending = gpuState.WorkSequence;
if (pending == 0 || pending > long.MaxValue)
{
return;
}
GuestGpu.Current.WaitForGuestWork(
(long)pending,
IndirectArgsFlushTimeoutMilliseconds);
}
private static bool TryReadSubmittedDrawCount(
CpuContext ctx,
SubmittedGpuState gpuState,
SubmittedDcbState state,
ulong packetAddress,
uint packetLength,
@@ -6196,17 +6489,89 @@ public static partial class AgcExports
drawCount = (control >> 21) & 0x7FFu;
return true;
case ItDrawIndirect or ItDrawIndexIndirect
when packetLength >= 5 && state.IndirectArgsAddress != 0:
if (!TryReadUInt32(ctx, packetAddress + 4, out var dataOffset))
case ItDrawIndexIndirectMulti when packetLength >= 8 &&
state.IndirectArgsAddress != 0:
if (!TryReadUInt32(ctx, packetAddress + 4, out var multiOffset) ||
!TryReadUInt32(ctx, packetAddress + 20, out var multiDraws) ||
!TryReadUInt32(ctx, packetAddress + 24, out var multiStride))
{
return false;
}
return TryReadUInt32(
ctx,
state.IndirectArgsAddress + dataOffset,
out drawCount);
if (multiStride < DrawIndexedIndirectArgsSize)
{
multiStride = DrawIndexedIndirectArgsSize;
}
var multiTotal = 0UL;
var multiCapped = multiDraws == 0
? DrawIndexedIndirectMaxScan
: Math.Min(multiDraws, 4096u);
for (var draw = 0u; draw < multiCapped; draw++)
{
if (!TryReadUInt32(
ctx,
state.IndirectArgsAddress + multiOffset + ((ulong)draw * multiStride),
out var subCount))
{
break;
}
if (subCount == 0 && multiDraws == 0)
{
break;
}
multiTotal += subCount;
}
var multiProbe = Interlocked.Increment(ref _indirectMultiProbeCount);
if (multiProbe <= 12 || multiProbe % 250 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] agc.draw_multi#{multiProbe} args=0x{state.IndirectArgsAddress:X} " +
$"off=0x{multiOffset:X} draws={multiDraws} stride={multiStride} " +
$"total={multiTotal}");
}
drawCount = (uint)Math.Min(multiTotal, uint.MaxValue);
return drawCount != 0;
case ItDrawIndirect or ItDrawIndexIndirect:
var probe = Interlocked.Increment(ref _indirectDrawProbeCount);
if (!TryReadUInt32(ctx, packetAddress + 4, out var dataOffset))
{
dataOffset = 0xFFFFFFFFu;
}
var readable = packetLength >= 5 &&
state.IndirectArgsAddress != 0 &&
dataOffset != 0xFFFFFFFFu;
var resolved = readable &&
TryReadUInt32(
ctx,
state.IndirectArgsAddress + dataOffset,
out drawCount);
if (probe <= 12 || probe % 100 == 0)
{
var dump = string.Empty;
for (var word = 0; word < 8; word++)
{
dump += TryReadUInt32(
ctx,
state.IndirectArgsAddress + dataOffset + ((ulong)word * 4),
out var raw)
? $" {raw}"
: " ?";
}
Console.Error.WriteLine(
$"[LOADER][WARN] agc.draw_indirect#{probe} op=0x{op:X} len={packetLength} " +
$"args=0x{state.IndirectArgsAddress:X} off=0x{dataOffset:X} " +
$"resolved={resolved} count={drawCount} words:{dump}");
}
return resolved;
default:
return false;
}
@@ -8056,6 +8421,61 @@ public static partial class AgcExports
source.Format == destination.Format;
}
private static readonly HashSet<ulong> _renderTargetAddresses = new();
private static readonly HashSet<ulong> _sampledRenderTargets = new();
private static readonly object _renderTargetProbeGate = new();
private static long _renderTargetSampleTraceCount;
private static long _indirectDrawProbeCount;
private static long _indirectDrawEmitCount;
private static long _indirectDrawEmitRejectCount;
private static long _indirectMultiProbeCount;
private static void NoteRenderTargetAddress(ulong address)
{
if (address == 0)
{
return;
}
lock (_renderTargetProbeGate)
{
if (_renderTargetAddresses.Count < 512)
{
_renderTargetAddresses.Add(address);
}
}
}
private static void NoteSampledAddress(ulong address, uint format = 0, uint numberType = 0)
{
if (address == 0)
{
return;
}
bool firstTime;
int distinctTargets;
lock (_renderTargetProbeGate)
{
if (!_renderTargetAddresses.Contains(address))
{
return;
}
firstTime = _sampledRenderTargets.Add(address);
distinctTargets = _renderTargetAddresses.Count;
}
var count = Interlocked.Increment(ref _renderTargetSampleTraceCount);
if (firstTime || count % 2000 == 0)
{
var gpuResident = GuestGpu.Current.IsGpuGuestImageAvailable(address, format, numberType);
Console.Error.WriteLine(
$"[LOADER][WARN] agc.rt_sampled#{count} addr=0x{address:X} first={firstTime} " +
$"gpu_resident={gpuResident} fmt={format}/{numberType} known_targets={distinctTargets}");
}
}
private static IReadOnlyList<RenderTargetDescriptor> GetRenderTargets(
IReadOnlyDictionary<uint, uint> registers,
bool includeMaskedTargets = false)
@@ -8082,6 +8502,13 @@ public static partial class AgcExports
continue;
}
if (targets.Exists(existing => existing.Address == address))
{
continue;
}
NoteRenderTargetAddress(address);
targets.Add(new RenderTargetDescriptor(
slot,
address,
@@ -8092,6 +8519,21 @@ public static partial class AgcExports
(attrib3 >> 14) & 0x1Fu));
}
if (targets.Count > 1 &&
targets.Select(t => t.Address).Distinct().Count() != targets.Count)
{
var dupCount = Interlocked.Increment(ref _duplicateTargetTraceCount);
if (dupCount <= 12 || dupCount % 500 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] agc.rt_duplicate#{dupCount} has_mask={hasTargetMask} " +
$"mask=0x{targetMask:X8} slots=[" +
string.Join(",", targets.Select(t =>
$"{t.Slot}:0x{t.Address:X}:m{(targetMask >> ((int)t.Slot * 4)) & 0xFu}")) +
"]");
}
}
return targets;
}
@@ -9331,6 +9773,7 @@ public static partial class AgcExports
descriptor.Format,
descriptor.NumberType))
{
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address,
descriptor.Width,
@@ -9410,6 +9853,7 @@ public static partial class AgcExports
$"tile={descriptor.TileMode} mip={mipLevel}");
}
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address,
descriptor.Width,
@@ -9470,6 +9914,7 @@ public static partial class AgcExports
descriptor.Type,
textureDepth)))
{
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address,
descriptor.Width,
@@ -9532,7 +9977,8 @@ public static partial class AgcExports
if (readAllLayers)
{
texture = new GuestDrawTexture(
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address,
descriptor.Width,
descriptor.Height,
@@ -9594,7 +10040,8 @@ public static partial class AgcExports
if (uploadedLayers == arrayLayers)
{
texture = new GuestDrawTexture(
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address,
descriptor.Width,
descriptor.Height,
@@ -9697,7 +10144,8 @@ public static partial class AgcExports
if (IsGpuDetileEquation(gpuDetileParams.Equation) &&
(long)elementsWide * elementsHigh * bytesPerElement <= source.Length)
{
texture = new GuestDrawTexture(
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address,
descriptor.Width,
descriptor.Height,
@@ -13312,6 +13760,58 @@ public static partial class AgcExports
return ReturnPointer(ctx, cmd);
}
// Matches the 4-dword INDIRECT_BUFFER packet CbBranch writes below.
[SysAbiExport(
Nid = "uZW-mqsxkrM",
ExportName = "sceAgcCbBranchGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int CbBranchGetSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 4u * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
// How a title continues a frame whose command arena filled: it branches from
// the tail of the exhausted buffer into a fresh one and submits only the first
// buffer, leaving the driver to follow the link. Dropping this packet strands
// everything written after the switch -- for UE 4.27 that is the rest of the
// frame, including its flip and the end-of-frame labels the guest's AGC
// interrupt thread needs before it will trigger the backbuffer event.
//
// The branch target and its length arrive on the stack, past six register
// arguments (verified against a live call: the values matched the continuation
// buffer the title had already written into).
[SysAbiExport(
Nid = "w1KFAHVqpaU",
ExportName = "sceAgcCbBranch",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int CbBranch(CpuContext ctx)
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
if (commandBufferAddress == 0 ||
!TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (2 * sizeof(ulong)), out var target) ||
!TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (3 * sizeof(ulong)), out var targetDwords))
{
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 4, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(4, ItIndirectBuffer, RZero)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)(target & 0xFFFF_FFFFUL)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)((target >> 32) & 0xFFFFUL)) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)targetDwords & 0xFFFFFu))
{
return ReturnPointer(ctx, 0);
}
TraceAgc(
$"agc.cb_branch buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} " +
$"target=0x{target:X16} dwords={targetDwords}");
return ReturnPointer(ctx, commandAddress);
}
[SysAbiExport(
Nid = "b-oySn+G2tE",
ExportName = "sceAgcAcbJumpGetSize",
+52 -1
View File
@@ -442,6 +442,50 @@ internal static class GpuWaitRegistry
return collected;
}
/// <summary>
/// Drops produced-label values that no registered waiter is watching. Called
/// under <see cref="_gate"/> when the table reaches its soft bound. A value
/// still watched by a waiter is the only thing that can release that waiter
/// once the guest recycles its label, so those are always retained even if
/// the table has to grow past the bound.
/// </summary>
private static void PruneUnwatchedProducedLocked()
{
List<(object Memory, ulong Address)>? unwatched = null;
foreach (var (key, _) in _lastProduced)
{
if (_waiters.TryGetValue(key.Item2, out var list))
{
var watched = false;
foreach (var waiter in list)
{
if (ReferenceEquals(waiter.Memory, key.Item1))
{
watched = true;
break;
}
}
if (watched)
{
continue;
}
}
(unwatched ??= []).Add(key);
}
if (unwatched is null)
{
return;
}
foreach (var key in unwatched)
{
_lastProduced.Remove(key);
}
}
/// <summary>Records the value a label producer wrote, for the deadlock
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
public static bool RecordProduced(object memory, ulong address, ulong value)
@@ -450,7 +494,14 @@ internal static class GpuWaitRegistry
{
if (_lastProduced.Count >= 8192)
{
_lastProduced.Clear();
// These entries are release state, not a cache. CollectDeadlockBroken
// can only free a waiter whose label the guest has since recycled by
// replaying the value a real producer wrote to it, so clearing the
// table wholesale strands every such waiter forever — the suspended
// queue then never resumes and the title wedges with its render
// thread parked. Drop only values no live waiter is watching, and
// let the table exceed the bound when they all are.
PruneUnwatchedProducedLocked();
}
_lastProduced[(memory, address)] = value;
+142 -14
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
@@ -25,7 +26,6 @@ public static class AmprExports
private const uint KernelEventQueueRecordType = 2;
private const uint WriteAddressRecordType = 3;
private static readonly ConcurrentDictionary<ulong, CommandBufferState> _commandBuffers = new();
private static readonly ConcurrentDictionary<string, Lazy<CachedHostFile>> _hostFileCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly bool _traceAmpr =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR"), "1", StringComparison.Ordinal);
private static readonly bool _traceAmprReads =
@@ -40,7 +40,7 @@ public static class AmprExports
public ulong CommandCount;
}
private sealed class CachedHostFile
private sealed class CachedHostFile : IDisposable
{
public CachedHostFile(string path)
{
@@ -55,8 +55,26 @@ public static class AmprExports
public SafeFileHandle Handle { get; }
public long Length { get; }
public void Dispose() => Handle.Dispose();
}
private sealed class CachedHostFileEntry
{
public required string Path { get; init; }
public required CachedHostFile File { get; init; }
}
// Keep a bounded LRU of open host files. An unbounded cache exhausts the
// process FD limit (~10k on macOS) during large asset storms, after
// which every new open throws IOException and surfaces as NOT_FOUND — the
// guest then reports InvalidFileFourCC on empty buffers.
private const int MaxCachedHostFiles = 1536;
private static readonly object _hostFileCacheGate = new();
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
new(StringComparer.OrdinalIgnoreCase);
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
[SysAbiExport(
Nid = "8aI7R7WaOlc",
ExportName = "sceAmprCommandBufferConstructor",
@@ -80,6 +98,7 @@ public static class AmprExports
}
TraceAmpr(ctx, "ctor", commandBuffer, buffer, size);
TryPreindexApp0();
ctx[CpuRegister.Rax] = commandBuffer;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -107,6 +126,7 @@ public static class AmprExports
}
TraceAmpr(ctx, "apr_ctor", commandBuffer, aux0, aux1);
TryPreindexApp0();
ctx[CpuRegister.Rax] = commandBuffer;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -271,8 +291,19 @@ public static class AmprExports
if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath))
{
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
// Cooked Insomniac content ids are FNV("/app0/...") and may never
// pass through APR resolve. Index app0 once, then retry the lookup.
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
if (!string.IsNullOrEmpty(app0Root))
{
AmprFileRegistry.EnsureApp0Indexed(app0Root);
}
if (!AmprFileRegistry.TryGetHostPath(fileId, out hostPath))
{
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
}
// Offset -1 means "continue after the previous read of this file id".
@@ -779,7 +810,9 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
const int ChunkSize = 1024 * 1024;
// 4 MiB chunks cut syscall/Rosetta round-trips on DeS' large sequential
// APR reads without blowing the ArrayPool for small probes.
const int ChunkSize = 4 * 1024 * 1024;
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min((ulong)ChunkSize, size));
try
@@ -857,27 +890,100 @@ public static class AmprExports
cachePath = hostPath;
}
var lazy = _hostFileCache.GetOrAdd(
cachePath,
static path => new Lazy<CachedHostFile>(() => new CachedHostFile(path), isThreadSafe: true));
lock (_hostFileCacheGate)
{
if (_hostFileByPath.TryGetValue(cachePath, out var existing))
{
_hostFileLru.Remove(existing);
_hostFileLru.AddFirst(existing);
file = existing.Value.File;
return true;
}
}
CachedHostFile opened;
try
{
file = lazy.Value;
return true;
opened = new CachedHostFile(cachePath);
}
catch (UnauthorizedAccessException)
{
_hostFileCache.TryRemove(cachePath, out _);
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
return false;
}
catch (IOException)
{
_hostFileCache.TryRemove(cachePath, out _);
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
return false;
// Likely EMFILE from a prior unbounded cache, or a transient miss.
// Evict everything we hold and retry once so a full FD table can
// recover without restarting the process.
EvictAllCachedHostFiles();
try
{
opened = new CachedHostFile(cachePath);
}
catch (UnauthorizedAccessException)
{
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
return false;
}
catch (IOException)
{
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
return false;
}
}
lock (_hostFileCacheGate)
{
if (_hostFileByPath.TryGetValue(cachePath, out var raced))
{
opened.Dispose();
_hostFileLru.Remove(raced);
_hostFileLru.AddFirst(raced);
file = raced.Value.File;
return true;
}
while (_hostFileByPath.Count >= MaxCachedHostFiles)
{
EvictLeastRecentlyUsedHostFileLocked();
}
var entry = new CachedHostFileEntry { Path = cachePath, File = opened };
var node = _hostFileLru.AddFirst(entry);
_hostFileByPath[cachePath] = node;
file = opened;
return true;
}
}
private static void EvictAllCachedHostFiles()
{
List<CachedHostFile> doomed;
lock (_hostFileCacheGate)
{
doomed = _hostFileLru.Select(entry => entry.File).ToList();
_hostFileLru.Clear();
_hostFileByPath.Clear();
}
foreach (var cached in doomed)
{
cached.Dispose();
}
}
private static void EvictLeastRecentlyUsedHostFileLocked()
{
var last = _hostFileLru.Last;
if (last is null)
{
return;
}
_hostFileLru.RemoveLast();
_hostFileByPath.Remove(last.Value.Path);
last.Value.File.Dispose();
}
private static bool AppendReadFileRecord(
@@ -1004,6 +1110,19 @@ public static class AmprExports
return false;
}
// GPU WAIT_REG_MEM often watches these APR completion labels
// (e.g. 0x20505xxx DEADBEEF/counter fences). Without
// RecordProduced the wait sits producerless after the guest recycles
// the dword, and CollectDeadlockBroken cannot replay the wake.
_ = GpuWaitRegistry.RecordProduced(ctx.Memory, address, value);
if ((address & 4ul) == 0)
{
// 32-bit GPU waits use the low dword; also latch that view when the
// address is dword-aligned so a u32 compare against ref sees it.
_ = GpuWaitRegistry.RecordProduced(
ctx.Memory, address, unchecked((uint)value));
}
TraceAmpr(ctx, "complete_write_address", address, value, 0);
return true;
}
@@ -1021,6 +1140,15 @@ public static class AmprExports
return true;
}
private static void TryPreindexApp0()
{
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
if (!string.IsNullOrEmpty(app0Root))
{
AmprFileRegistry.EnsureApp0Indexed(app0Root);
}
}
private static void TraceAmpr(CpuContext ctx, string operation, ulong commandBuffer, ulong arg0, ulong arg1)
{
if (!_traceAmpr)
+570 -9
View File
@@ -2,15 +2,35 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
namespace SharpEmu.Libs.Ampr;
internal static class AmprFileRegistry
{
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new();
private const uint CacheMagicV2 = 0x32495041u; // 'API2'
private const uint CacheVersionV2 = 2;
private const uint CacheMagicV3 = 0x33495041u; // 'API3'
private const uint CacheVersionV3 = 3;
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new(
concurrencyLevel: Math.Max(4, Environment.ProcessorCount),
capacity: 1_048_576);
private static readonly object _indexGate = new();
private static string? _indexedApp0Root;
private static string? _indexingApp0Root;
private static int _preloadStarted;
public static uint Register(string guestPath, string hostPath)
{
if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0)
{
RegisterApp0Relative(relative, hostPath);
return ComputeFileId("$/" + relative);
}
var id = ComputeFileId(guestPath);
_hostPathsById[id] = hostPath;
return id;
@@ -21,18 +41,559 @@ internal static class AmprFileRegistry
return _hostPathsById.TryGetValue(id, out hostPath!);
}
/// <summary>Test hook: wipe registry state between cases.</summary>
internal static void ClearForTests()
{
lock (_indexGate)
{
_hostPathsById.Clear();
_indexedApp0Root = null;
_indexingApp0Root = null;
_preloadStarted = 0;
}
}
/// <summary>Test hook for the allocation-free alias publisher.</summary>
internal static void RegisterApp0RelativeForTests(string relative, string hostPath) =>
RegisterApp0Relative(relative, hostPath);
/// <summary>
/// Kick off <see cref="EnsureApp0Indexed"/> on a background thread as soon as
/// the host knows app0. otherwise pays the full tree walk on the
/// first cooked-id APR miss mid-boot (~8s under Rosetta for DeS).
/// </summary>
public static void BeginApp0IndexPreload(string? app0Root)
{
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
{
return;
}
if (Interlocked.Exchange(ref _preloadStarted, 1) != 0)
{
return;
}
var root = app0Root;
ThreadPool.UnsafeQueueUserWorkItem(
static state =>
{
try
{
EnsureApp0Indexed((string)state!);
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] ampr.app0_index_preload_failed: {exception.Message}");
}
},
root);
}
/// <summary>
/// Indexes every file under app0 under both <c>$/</c> and <c>/app0/</c> FNV
/// ids. Cooked asset tables ship precomputed ids; without this walk, a title
/// that never resolves those paths through APR leaves ReadFile permanently
/// NOT_FOUND.
/// </summary>
public static void EnsureApp0Indexed(string app0Root)
{
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
{
return;
}
var normalizedRoot = Path.GetFullPath(app0Root);
var stopwatch = Stopwatch.StartNew();
lock (_indexGate)
{
while (true)
{
if (string.Equals(_indexedApp0Root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (_indexingApp0Root is not null)
{
// Another thread (preload) owns the walk — wait instead of
// stacking a second 8s index on the guest APR miss path.
if (string.Equals(
_indexingApp0Root,
normalizedRoot,
StringComparison.OrdinalIgnoreCase))
{
Monitor.Wait(_indexGate);
continue;
}
Monitor.Wait(_indexGate, 50);
continue;
}
_indexingApp0Root = normalizedRoot;
break;
}
}
try
{
var cachePathV3 = GetIndexCachePath(normalizedRoot, version: 3);
var cachePathV2 = GetIndexCachePath(normalizedRoot, version: 2);
if (TryLoadIndexCache(normalizedRoot, cachePathV3, preferV3: true, out var cachedFiles))
{
lock (_indexGate)
{
_indexedApp0Root = normalizedRoot;
}
Console.Error.WriteLine(
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
$"files={cachedFiles} ids={_hostPathsById.Count} " +
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
return;
}
if (TryLoadIndexCache(normalizedRoot, cachePathV2, preferV3: false, out cachedFiles))
{
lock (_indexGate)
{
_indexedApp0Root = normalizedRoot;
}
// Promote v2 (rehash-on-load) to v3 (precomputed ids) so the
// next boot skips the Rosetta FNV storm.
TrySaveIndexCache(normalizedRoot, cachePathV3, cachedFiles);
Console.Error.WriteLine(
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
$"files={cachedFiles} ids={_hostPathsById.Count} " +
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1} upgraded=v3");
return;
}
var relatives = new List<string>(256 * 1024);
foreach (var hostPath in Directory.EnumerateFiles(
normalizedRoot,
"*",
SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
.Replace('\\', '/');
if (string.IsNullOrEmpty(relative) ||
relative.StartsWith("..", StringComparison.Ordinal))
{
continue;
}
relatives.Add(relative);
}
// Hash + dictionary fill dominates under Rosetta once the walk is
// done; parallelize across cores without re-walking the tree.
Parallel.ForEach(
relatives,
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
},
relative =>
{
var hostPath = Path.Combine(normalizedRoot, relative.Replace('/', Path.DirectorySeparatorChar));
RegisterApp0Relative(relative, hostPath);
});
lock (_indexGate)
{
_indexedApp0Root = normalizedRoot;
}
TrySaveIndexCache(normalizedRoot, cachePathV3, relatives.Count);
Console.Error.WriteLine(
$"[LOADER][INFO] ampr.app0_indexed root={normalizedRoot} " +
$"files={relatives.Count} ids={_hostPathsById.Count} " +
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
}
finally
{
lock (_indexGate)
{
_indexingApp0Root = null;
Monitor.PulseAll(_indexGate);
}
}
}
/// <summary>
/// Registers the four Insomniac path aliases for one app0-relative file
/// without allocating intermediate guest-path strings.
/// </summary>
private static void RegisterApp0Relative(string relative, string hostPath)
{
// "$/" + relative
Publish(FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'), relative, hostPath);
// "/app0/" + relative
Publish(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative, hostPath);
// "app0/" + relative
Publish(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative, hostPath);
// bare relative
Publish(OffsetBasis, relative, hostPath);
}
private static void Publish(uint hash, string relative, string hostPath)
{
_hostPathsById[FnvContinueUtf8(hash, relative)] = hostPath;
}
internal static uint ComputeFileId(string guestPath)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(guestPath);
return FnvContinueUtf8(OffsetBasis, guestPath);
}
const uint offsetBasis = 2166136261;
const uint prime = 16777619;
var hash = offsetBasis;
foreach (var b in bytes)
internal static IEnumerable<string> EnumerateApp0PathAliases(string guestPath)
{
if (string.IsNullOrEmpty(guestPath))
{
hash ^= b;
hash *= prime;
yield break;
}
if (!TryGetApp0Relative(guestPath, out var relative) ||
string.IsNullOrEmpty(relative))
{
yield break;
}
yield return "$/" + relative;
yield return "/app0/" + relative;
yield return "app0/" + relative;
yield return relative;
}
private static bool TryGetApp0Relative(string guestPath, out string relative)
{
relative = string.Empty;
var normalized = guestPath.Replace('\\', '/');
if (normalized.StartsWith("$/", StringComparison.Ordinal))
{
relative = normalized[2..].TrimStart('/');
return relative.Length != 0;
}
if (normalized.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase))
{
relative = normalized["/app0/".Length..].TrimStart('/');
return relative.Length != 0;
}
if (normalized.StartsWith("app0/", StringComparison.OrdinalIgnoreCase))
{
relative = normalized["app0/".Length..].TrimStart('/');
return relative.Length != 0;
}
// Bare relative paths are treated as app0-relative by ResolveGuestPath.
if (!normalized.StartsWith('/') &&
!Path.IsPathFullyQualified(guestPath))
{
relative = normalized.TrimStart('/');
return relative.Length != 0;
}
return false;
}
private static string GetIndexCachePath(string normalizedRoot, int version)
{
var overrideDir = Environment.GetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE");
var cacheDir = !string.IsNullOrWhiteSpace(overrideDir)
? overrideDir
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SharpEmu",
"ampr-index");
Directory.CreateDirectory(cacheDir);
var rootHash = ComputeFileId(normalizedRoot.ToLowerInvariant());
return Path.Combine(cacheDir, $"app0-{rootHash:x8}.v{version}.idx");
}
private static bool TryLoadIndexCache(
string normalizedRoot,
string cachePath,
bool preferV3,
out int fileCount)
{
fileCount = 0;
try
{
if (!File.Exists(cachePath))
{
return false;
}
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_AMPR_REINDEX"),
"1",
StringComparison.Ordinal))
{
return false;
}
using var stream = File.OpenRead(cachePath);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false);
var magic = reader.ReadUInt32();
var version = reader.ReadUInt32();
var isV3 = magic == CacheMagicV3 && version == CacheVersionV3;
var isV2 = magic == CacheMagicV2 && version == CacheVersionV2;
if (preferV3)
{
if (!isV3)
{
return false;
}
}
else if (!isV2)
{
return false;
}
var root = reader.ReadString();
if (!string.Equals(root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var expectedFiles = reader.ReadInt32();
var expectedParamTicks = reader.ReadInt64();
var actualParamTicks = GetParamJsonWriteTicks(normalizedRoot);
if (actualParamTicks == 0 || actualParamTicks != expectedParamTicks)
{
return false;
}
if (expectedFiles < 0 || expectedFiles > 8_000_000)
{
return false;
}
if (isV3)
{
// Precomputed FNV ids — no Rosetta hash storm on every boot.
var entries = new (string Relative, uint Id0, uint Id1, uint Id2, uint Id3)[expectedFiles];
for (var i = 0; i < expectedFiles; i++)
{
entries[i] = (
reader.ReadString(),
reader.ReadUInt32(),
reader.ReadUInt32(),
reader.ReadUInt32(),
reader.ReadUInt32());
}
Parallel.ForEach(
entries,
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
},
entry =>
{
if (string.IsNullOrEmpty(entry.Relative) ||
entry.Relative.Contains("..", StringComparison.Ordinal))
{
return;
}
var hostPath = normalizedRoot.EndsWith(Path.DirectorySeparatorChar)
? normalizedRoot + entry.Relative.Replace('/', Path.DirectorySeparatorChar)
: normalizedRoot + Path.DirectorySeparatorChar +
entry.Relative.Replace('/', Path.DirectorySeparatorChar);
_hostPathsById[entry.Id0] = hostPath;
_hostPathsById[entry.Id1] = hostPath;
_hostPathsById[entry.Id2] = hostPath;
_hostPathsById[entry.Id3] = hostPath;
});
}
else
{
var relatives = new string[expectedFiles];
for (var i = 0; i < expectedFiles; i++)
{
relatives[i] = reader.ReadString();
}
Parallel.ForEach(
relatives,
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
},
relative =>
{
if (string.IsNullOrEmpty(relative) ||
relative.Contains("..", StringComparison.Ordinal))
{
return;
}
var hostPath = Path.Combine(
normalizedRoot,
relative.Replace('/', Path.DirectorySeparatorChar));
RegisterApp0Relative(relative, hostPath);
});
}
fileCount = expectedFiles;
return true;
}
catch (Exception exception)
{
_hostPathsById.Clear();
Console.Error.WriteLine(
$"[LOADER][WARN] ampr.app0_index_cache_load_failed: {exception.Message}");
return false;
}
}
private static void TrySaveIndexCache(
string normalizedRoot,
string cachePath,
int fileCount)
{
try
{
var paramTicks = GetParamJsonWriteTicks(normalizedRoot);
if (paramTicks == 0 || fileCount <= 0)
{
return;
}
var relatives = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var hostPath in _hostPathsById.Values)
{
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
.Replace('\\', '/');
if (string.IsNullOrEmpty(relative) ||
relative.StartsWith("..", StringComparison.Ordinal))
{
continue;
}
relatives.Add(relative);
}
var tempPath = cachePath + ".tmp";
using (var stream = File.Create(tempPath))
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: false))
{
writer.Write(CacheMagicV3);
writer.Write(CacheVersionV3);
writer.Write(normalizedRoot);
writer.Write(relatives.Count);
writer.Write(paramTicks);
foreach (var relative in relatives)
{
writer.Write(relative);
// Mirror RegisterApp0Relative id order: $/ /app0/ app0/ bare.
writer.Write(ComputeApp0AliasIds(relative, out var id1, out var id2, out var id3));
writer.Write(id1);
writer.Write(id2);
writer.Write(id3);
}
}
File.Move(tempPath, cachePath, overwrite: true);
Console.Error.WriteLine(
$"[LOADER][INFO] ampr.app0_index_cache_saved path={cachePath} " +
$"files={relatives.Count}");
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] ampr.app0_index_cache_save_failed: {exception.Message}");
}
}
private static uint ComputeApp0AliasIds(
string relative,
out uint app0Slash,
out uint app0,
out uint bare)
{
var dollar = FnvContinueUtf8(
FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'),
relative);
app0Slash = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative);
app0 = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative);
bare = FnvContinueUtf8(OffsetBasis, relative);
return dollar;
}
/// <summary>
/// Cheap dump fingerprint. Full-tree walks are too expensive for cache
/// validation; param.json changes with title updates. Force a rebuild with
/// SHARPEMU_AMPR_REINDEX=1 after manual dump edits.
/// </summary>
private static long GetParamJsonWriteTicks(string normalizedRoot)
{
try
{
var paramPath = Path.Combine(normalizedRoot, "sce_sys", "param.json");
return File.Exists(paramPath)
? File.GetLastWriteTimeUtc(paramPath).Ticks
: 0;
}
catch
{
return 0;
}
}
private const uint OffsetBasis = 2166136261;
private const uint FnvPrime = 16777619;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static uint FnvContinueAscii(uint hash, byte value)
{
hash ^= value;
return hash * FnvPrime;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static uint FnvContinueAsciiPrefix(uint hash, ReadOnlySpan<byte> ascii)
{
foreach (var value in ascii)
{
hash ^= value;
hash *= FnvPrime;
}
return hash;
}
private static uint FnvContinueUtf8(uint hash, string text)
{
// Game asset paths are overwhelmingly ASCII; avoid Encoding.GetBytes
// allocations on the 223k-file DeS index hot path.
Span<byte> utf8Scratch = stackalloc byte[4];
for (var i = 0; i < text.Length; i++)
{
var ch = text[i];
if (ch < 0x80)
{
hash ^= (byte)ch;
hash *= FnvPrime;
continue;
}
var written = Encoding.UTF8.GetBytes(text.AsSpan(i, 1), utf8Scratch);
for (var b = 0; b < written; b++)
{
hash ^= utf8Scratch[b];
hash *= FnvPrime;
}
}
return hash;
+172 -320
View File
@@ -3,6 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.Media;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Globalization;
@@ -15,6 +16,10 @@ public static class AvPlayerExports
private const int InvalidParameters = unchecked((int)0x806A0001);
private const int OperationFailed = unchecked((int)0x806A0002);
private const int FrameBufferCount = 3;
private const int MaxCatchUpFrames = 2;
private const ulong TextureAllocationAlignment = 0x100;
private const int FramePitchAlignment = 64;
private const int FrameHeightAlignment = 16;
private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104;
// This structure is 32 bytes. A larger write can damage the guest stack.
@@ -22,6 +27,7 @@ public static class AvPlayerExports
private const int StreamInfoExSize = 32;
private const int MaxGuestPathLength = 4096;
private static readonly object StateGate = new();
private static readonly HashSet<string> TracedOnce = new();
private static readonly Dictionary<ulong, PlayerState> Players = new();
private static int _traceCount;
@@ -31,6 +37,7 @@ public static class AvPlayerExports
public bool AutoStart { get; init; }
public ulong AllocatorObject { get; init; }
public ulong AllocateTextureCallback { get; init; }
public ulong AllocateCallback { get; init; }
public ulong EventObject { get; init; }
public ulong EventCallback { get; init; }
public string? SourcePath { get; set; }
@@ -42,11 +49,10 @@ public static class AvPlayerExports
public bool Paused { get; set; }
public bool Looping { get; set; }
public bool EndOfStream { get; set; }
public Process? Decoder { get; set; }
public Stream? DecoderOutput { get; set; }
public Process? AudioDecoder { get; set; }
public Stream? AudioDecoderOutput { get; set; }
public Stopwatch PlaybackClock { get; } = new();
public long SkippedFrameDebt { get; set; }
public byte[]? RawFrame { get; set; }
public byte[]? RawAudioFrame { get; set; }
public byte[]? PaddedFrame { get; set; }
@@ -66,42 +72,6 @@ public static class AvPlayerExports
DecoderOutput = null;
AudioDecoderOutput?.Dispose();
AudioDecoderOutput = null;
if (Decoder is not null)
{
try
{
if (!Decoder.HasExited)
{
Decoder.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
Decoder.Dispose();
Decoder = null;
}
}
if (AudioDecoder is not null)
{
try
{
if (!AudioDecoder.HasExited)
{
AudioDecoder.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
AudioDecoder.Dispose();
AudioDecoder = null;
}
}
}
public void ResetPlayback()
@@ -110,6 +80,7 @@ public static class AvPlayerExports
PlaybackClock.Reset();
NextFrameIndex = 0;
NextAudioFrameIndex = 0;
SkippedFrameDebt = 0;
EndOfStream = false;
}
}
@@ -137,6 +108,7 @@ public static class AvPlayerExports
AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0,
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0,
});
@@ -191,6 +163,7 @@ public static class AvPlayerExports
AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0,
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0,
});
@@ -356,7 +329,7 @@ public static class AvPlayerExports
}
player.Paused = false;
if (player.Decoder is not null)
if (player.DecoderOutput is not null)
{
player.PlaybackClock.Start();
}
@@ -388,7 +361,13 @@ public static class AvPlayerExports
ExportName = "sceAvPlayerEnableStream",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerEnableStream(CpuContext ctx) => ValidatePlayer(ctx);
public static int AvPlayerEnableStream(CpuContext ctx)
{
TraceOnce(
$"enable_stream_{ctx[CpuRegister.Rsi]}",
$"enable_stream index={ctx[CpuRegister.Rsi]}");
return ValidatePlayer(ctx);
}
[SysAbiExport(
Nid = "k-q+xOxdc3E",
@@ -445,10 +424,13 @@ public static class AvPlayerExports
{
lock (StateGate)
{
return SetReturn(
ctx,
Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) &&
player.Started && !player.EndOfStream ? 1 : 0);
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
var active = found && player!.Started && !player.EndOfStream;
TraceOnce(
"is_active",
$"is_active found={found} started={(found && player!.Started)} " +
$"eos={(found && player!.EndOfStream)} returned={(active ? 1 : 0)}");
return SetReturn(ctx, active ? 1 : 0);
}
}
@@ -476,13 +458,21 @@ public static class AvPlayerExports
var infoAddress = ctx[CpuRegister.Rsi];
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
infoAddress == 0 || !player.Started || player.Paused || player.EndOfStream ||
player.SourcePath is null || !EnsureAudioDecoder(player))
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
if (!found || infoAddress == 0 || !player!.Started || player.Paused ||
player.EndOfStream || player.SourcePath is null || !EnsureAudioDecoder(player))
{
TraceOnce(
"audio_data_refused",
$"audio_data refused found={found} info=0x{infoAddress:X16} " +
$"started={(found && player!.Started)} paused={(found && player!.Paused)} " +
$"eos={(found && player!.EndOfStream)} " +
$"decoder={(found && player!.SourcePath is not null && EnsureAudioDecoder(player))}");
return SetReturn(ctx, 0);
}
TraceOnce("audio_data_ok", "audio_data first delivery");
const int samplesPerFrame = 1024;
const int channelCount = 2;
const int sampleRate = 48_000;
@@ -560,7 +550,9 @@ public static class AvPlayerExports
{
lock (StateGate)
{
return SetReturn(ctx, Players.ContainsKey(ctx[CpuRegister.Rdi]) ? 2 : InvalidParameters);
var known = Players.ContainsKey(ctx[CpuRegister.Rdi]);
TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}");
return SetReturn(ctx, known ? 2 : InvalidParameters);
}
}
@@ -637,6 +629,10 @@ public static class AvPlayerExports
return SetReturn(ctx, InvalidParameters);
}
TraceOnce(
$"stream_info_{streamIndex}_{infoSize}",
$"stream_info index={streamIndex} size={infoSize} " +
$"type={(streamIndex == 0 ? "video" : "audio")} duration_ms={player.DurationMilliseconds}");
return SetReturn(ctx, 0);
}
}
@@ -672,6 +668,8 @@ public static class AvPlayerExports
}
EnsureGuestVideoBuffers(ctx, player);
NotifyEvent(ctx, player, 2); // StateReady
if (autoStart)
{
@@ -699,7 +697,20 @@ public static class AvPlayerExports
}
var fps = Math.Max(1.0, player.FramesPerSecond);
var expectedFrame = (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps);
var expectedFrame =
(long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps) -
player.SkippedFrameDebt;
var behind = expectedFrame - player.NextFrameIndex;
if (behind > MaxCatchUpFrames)
{
player.SkippedFrameDebt += behind - MaxCatchUpFrames;
expectedFrame = player.NextFrameIndex + MaxCatchUpFrames;
TraceOnce(
"catch_up_capped",
$"catch_up capped behind={behind} max={MaxCatchUpFrames} " +
$"fps={fps:F3} {player.Width}x{player.Height}");
}
while (player.NextFrameIndex < expectedFrame)
{
if (!ReadFrame(player))
@@ -748,62 +759,28 @@ public static class AvPlayerExports
return true;
}
var ffmpeg = FindFfmpeg();
if (ffmpeg is null || player.SourcePath is null)
if (player.SourcePath is null)
{
Console.Error.WriteLine("[AVPLAYER][ERROR] FFmpeg was not found. Set SHARPEMU_FFMPEG_PATH.");
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
if (!FfmpegMediaStream.TryOpenVideo(
player.SourcePath,
checked((int)player.Width),
checked((int)player.Height),
out var videoStream) ||
videoStream is null)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(player.SourcePath);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("nv12");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
player.Decoder = Process.Start(startInfo);
if (player.Decoder is null)
{
return false;
}
player.Decoder.ErrorDataReceived += (_, eventArgs) =>
{
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
{
Console.Error.WriteLine($"[AVPLAYER][FFMPEG] {eventArgs.Data}");
}
};
player.Decoder.BeginErrorReadLine();
player.DecoderOutput = player.Decoder.StandardOutput.BaseStream;
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
player.PlaybackClock.Start();
Trace($"decoder_started pid={player.Decoder.Id} source='{player.SourcePath}'");
return true;
}
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg: {exception.Message}");
player.Dispose();
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] Could not open a video stream in '{player.SourcePath}'.");
return false;
}
player.DecoderOutput = videoStream;
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
player.PlaybackClock.Start();
Trace($"decoder_started source='{player.SourcePath}' {player.Width}x{player.Height} nv12");
return true;
}
private static bool EnsureAudioDecoder(PlayerState player)
@@ -813,65 +790,21 @@ public static class AvPlayerExports
return true;
}
var ffmpeg = FindFfmpeg();
if (ffmpeg is null || player.SourcePath is null)
if (player.SourcePath is null)
{
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
if (!FfmpegMediaStream.TryOpenAudio(player.SourcePath, out var audioStream) ||
audioStream is null)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(player.SourcePath);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:a:0");
startInfo.ArgumentList.Add("-vn");
startInfo.ArgumentList.Add("-ac");
startInfo.ArgumentList.Add("2");
startInfo.ArgumentList.Add("-ar");
startInfo.ArgumentList.Add("48000");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("s16le");
startInfo.ArgumentList.Add("pipe:1");
try
{
player.AudioDecoder = Process.Start(startInfo);
if (player.AudioDecoder is null)
{
return false;
}
player.AudioDecoder.ErrorDataReceived += (_, eventArgs) =>
{
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
{
Console.Error.WriteLine($"[AVPLAYER][FFMPEG-AUDIO] {eventArgs.Data}");
}
};
player.AudioDecoder.BeginErrorReadLine();
player.AudioDecoderOutput = player.AudioDecoder.StandardOutput.BaseStream;
player.RawAudioFrame = new byte[1024 * 2 * sizeof(short)];
Trace($"audio_decoder_started pid={player.AudioDecoder.Id} source='{player.SourcePath}'");
return true;
}
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg audio decoder: {exception.Message}");
player.AudioDecoderOutput?.Dispose();
player.AudioDecoderOutput = null;
player.AudioDecoder?.Dispose();
player.AudioDecoder = null;
return false;
}
player.AudioDecoderOutput = audioStream;
player.RawAudioFrame = new byte[1024 * FfmpegMediaStream.AudioChannels * sizeof(short)];
Trace($"audio_decoder_started source='{player.SourcePath}' s16 stereo 48000");
return true;
}
private static bool ReadFrame(PlayerState player)
@@ -923,9 +856,9 @@ public static class AvPlayerExports
return false;
}
var alignedWidth = AlignUp(player.Width, 16);
var alignedHeight = AlignUp(player.Height, 16);
var bufferStride = checked(alignedWidth * alignedHeight * 3 / 2);
var alignedWidth = AlignUp(player.Width, FramePitchAlignment);
var alignedHeight = AlignUp(player.Height, FrameHeightAlignment);
var bufferStride = GetVideoBufferSize(player);
if (player.GuestBuffers[0] == 0)
{
if (!AllocateGuestVideoBuffers(ctx, player, bufferStride))
@@ -981,39 +914,78 @@ public static class AvPlayerExports
return ctx.Memory.TryWrite(infoAddress, info);
}
private static int GetVideoBufferSize(PlayerState player) =>
checked(
AlignUp(player.Width, FramePitchAlignment) *
AlignUp(player.Height, FrameHeightAlignment) * 3 / 2);
private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player)
{
lock (StateGate)
{
if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0)
{
return;
}
var bufferSize = GetVideoBufferSize(player);
if (AllocateGuestVideoBuffers(ctx, player, bufferSize))
{
player.GuestBufferStride = bufferSize;
}
}
}
private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize)
{
var scheduler = GuestThreadExecution.Scheduler;
if (!player.TextureAllocatorFailed && player.AllocateTextureCallback != 0 && scheduler is not null)
if (!player.TextureAllocatorFailed && scheduler is not null)
{
for (var index = 0; index < player.GuestBuffers.Length; index++)
foreach (var (callback, kind) in new[]
{
(player.AllocateTextureCallback, "texture"),
(player.AllocateCallback, "generic"),
})
{
if (!scheduler.TryCallGuestFunction(
ctx,
player.AllocateTextureCallback,
player.AllocatorObject,
0x100,
checked((ulong)bufferSize),
0,
0,
"avplayer_allocate_texture",
out var buffer,
out var error) || buffer == 0)
if (callback == 0)
{
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] Guest texture allocation failed index={index} " +
$"callback=0x{player.AllocateTextureCallback:X16}: {error ?? "returned null"}");
player.TextureAllocatorFailed = true;
Array.Clear(player.GuestBuffers);
break;
continue;
}
var allocated = true;
for (var index = 0; index < player.GuestBuffers.Length; index++)
{
if (!scheduler.TryCallGuestFunction(
ctx,
callback,
player.AllocatorObject,
TextureAllocationAlignment,
checked((ulong)bufferSize),
0,
0,
"avplayer_allocate_" + kind,
out var buffer,
out var error) || buffer == 0)
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Guest {kind} allocation failed index={index} " +
$"callback=0x{callback:X16} size={bufferSize} " +
$"align=0x{TextureAllocationAlignment:X}: {error ?? "returned null"}");
allocated = false;
Array.Clear(player.GuestBuffers);
break;
}
player.GuestBuffers[index] = buffer;
Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
}
if (allocated)
{
return true;
}
player.GuestBuffers[index] = buffer;
Trace($"texture_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
}
if (!player.TextureAllocatorFailed)
{
return true;
}
player.TextureAllocatorFailed = true;
}
if (!KernelMemoryCompatExports.TryAllocateHleData(
@@ -1043,158 +1015,25 @@ public static class AvPlayerExports
height = 0;
framesPerSecond = 30.0;
durationMilliseconds = 0;
var ffmpeg = FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
if (!File.Exists(ffprobe))
if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration))
{
return false;
}
var startInfo = new ProcessStartInfo(ffprobe)
if (rate > 0)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-select_streams");
startInfo.ArgumentList.Add("v:0");
startInfo.ArgumentList.Add("-show_entries");
startInfo.ArgumentList.Add("stream=width,height,avg_frame_rate,duration");
startInfo.ArgumentList.Add("-of");
startInfo.ArgumentList.Add("default=noprint_wrappers=1");
startInfo.ArgumentList.Add(path);
try
{
using var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Console.Error.WriteLine($"[AVPLAYER][FFPROBE] {error.Trim()}");
return false;
}
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separator = line.IndexOf('=');
if (separator < 1)
{
continue;
}
var key = line[..separator];
var value = line[(separator + 1)..];
switch (key)
{
case "width":
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
break;
case "height":
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
break;
case "avg_frame_rate":
var parts = value.Split('/');
if (parts.Length == 2 &&
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator) &&
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) &&
denominator != 0)
{
framesPerSecond = numerator / denominator;
}
break;
case "duration":
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var duration))
{
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
}
break;
}
}
return width > 0 && height > 0 && framesPerSecond > 0;
framesPerSecond = rate;
}
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
if (duration > 0)
{
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to probe video: {exception.Message}");
return false;
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
}
return width > 0 && height > 0 && framesPerSecond > 0;
}
internal static string? FindFfmpeg() =>
FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows(),
AppContext.BaseDirectory);
internal static string? FindFfmpeg(
string? configured,
string? searchPath,
bool isWindows,
string? baseDirectory = null)
{
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return configured;
}
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
if (!string.IsNullOrWhiteSpace(baseDirectory))
{
foreach (var candidate in new[]
{
Path.Combine(baseDirectory, executable),
Path.Combine(baseDirectory, "ffmpeg", executable),
})
{
if (File.Exists(candidate))
{
return candidate;
}
}
}
foreach (var directory in (searchPath ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
if (File.Exists(candidate))
{
return candidate;
}
}
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
Path.Combine(
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
isWindows ? "ffprobe.exe" : "ffprobe");
private static string RemovePathQuotes(string directory) =>
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
? directory[1..^1]
: directory;
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
@@ -1606,4 +1445,17 @@ public static class AvPlayerExports
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
}
}
private static void TraceOnce(string key, string message)
{
lock (TracedOnce)
{
if (!TracedOnce.Add(key))
{
return;
}
}
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
}
}
@@ -1,166 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.Libs.AvPlayer;
namespace SharpEmu.Libs.Bink;
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
{
private readonly Process _process;
private readonly Stream _output;
private int _errorLines;
private int _disposed;
private FfmpegBinkFrameSource(
Process process,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_process = process;
_output = process.StandardOutput.BaseStream;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
internal static bool TryOpen(
string path,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator,
out FfmpegBinkFrameSource? source)
{
source = null;
var ffmpeg = AvPlayerExports.FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("bgra");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
source = new FfmpegBinkFrameSource(
process,
width,
height,
framesPerSecondNumerator,
framesPerSecondDenominator);
process.ErrorDataReceived += source.OnErrorData;
process.BeginErrorReadLine();
return true;
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException or
System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
return false;
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
try
{
var offset = 0;
while (offset < destination.Length)
{
var read = _output.Read(destination[offset..]);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
{
if (Volatile.Read(ref _disposed) == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
}
return false;
}
}
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
{
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
Interlocked.Increment(ref _errorLines) > 20)
{
return;
}
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_output.Dispose();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
_process.Dispose();
}
}
}
+151
View File
@@ -8,7 +8,13 @@ namespace SharpEmu.Libs.Font;
public static class FontExports
{
private const ushort GlyphMagic = 0x0F03;
private const int GlyphSize = 0x100;
private const int GlyphMetricsSize = 8 * sizeof(float);
private const int RenderOutputSize = 0x40;
private static readonly object AllocationGate = new();
private static readonly Stack<ulong> FreeGlyphs = new();
private static ulong _librarySelectionAddress;
private static ulong _rendererSelectionAddress;
@@ -321,6 +327,151 @@ public static class FontExports
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "C-4Qw5Srlyw",
ExportName = "sceFontGenerateCharGlyph",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GenerateCharGlyph(CpuContext ctx)
{
var outputAddress = ctx[CpuRegister.Rcx];
if (outputAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!TryRentGlyph(ctx, out var glyph) ||
!ctx.TryWriteUInt64(outputAddress, glyph))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "8-zmgsxkBek",
ExportName = "sceFontGlyphDefineAttribute",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GlyphDefineAttribute(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "LHDoRWVFGqk",
ExportName = "sceFontDeleteGlyph",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int DeleteGlyph(CpuContext ctx)
{
var glyphPointerAddress = ctx[CpuRegister.Rsi];
if (glyphPointerAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!ctx.TryReadUInt64(glyphPointerAddress, out var glyph))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (glyph != 0)
{
lock (AllocationGate)
{
FreeGlyphs.Push(glyph);
}
}
return ctx.TryWriteUInt64(glyphPointerAddress, 0)
? SetSuccess(ctx)
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "kAenWy1Zw5o",
ExportName = "sceFontRenderCharGlyphImageHorizontal",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int RenderCharGlyphImageHorizontal(CpuContext ctx)
{
var metricsAddress = ctx[CpuRegister.Rcx];
var resultAddress = ctx[CpuRegister.R8];
if (metricsAddress != 0)
{
var values = new[] { 8.0f, 16.0f, 0.0f, 12.0f, 8.0f, 0.0f, 0.0f, 16.0f };
for (var index = 0; index < values.Length; index++)
{
if (!TryWriteUInt32(
ctx,
metricsAddress + (ulong)(index * sizeof(float)),
BitConverter.SingleToUInt32Bits(values[index])))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
}
if (resultAddress != 0)
{
Span<byte> cleared = stackalloc byte[RenderOutputSize];
cleared.Clear();
if (!ctx.Memory.TryWrite(resultAddress, cleared))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "vzHs3C8lWJk",
ExportName = "sceFontCloseFont",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int CloseFont(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "1QjhKxrsOB8",
ExportName = "sceFontUnbindRenderer",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int UnbindRenderer(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "exAxkyVLt0s",
ExportName = "sceFontDestroyRenderer",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int DestroyRenderer(CpuContext ctx)
{
var rendererPointerAddress = ctx[CpuRegister.Rdi];
if (rendererPointerAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return ctx.TryWriteUInt64(rendererPointerAddress, 0)
? SetSuccess(ctx)
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
private static bool TryRentGlyph(CpuContext ctx, out ulong glyph)
{
lock (AllocationGate)
{
if (FreeGlyphs.Count > 0)
{
glyph = FreeGlyphs.Pop();
return TryWriteUInt16(ctx, glyph, GlyphMagic);
}
}
return TryAllocateOpaque(ctx, GlyphSize, out glyph) &&
TryWriteUInt16(ctx, glyph, GlyphMagic);
}
private static int ReturnSelection(CpuContext ctx, ref ulong selectionAddress, uint objectSize)
{
if (ctx[CpuRegister.Rdi] != 0)
@@ -253,7 +253,7 @@ internal static partial class MetalVideoPresenter
if (writeBackBuffers.Count > 0)
{
var committed = FlushBatchedGuestCommands();
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
WaitForCommittedCommandBuffer(committed);
WriteBuffersBackToGuest(writeBackBuffers);
}
@@ -580,7 +580,7 @@ internal static partial class MetalVideoPresenter
if (writeBackBuffers.Count > 0)
{
var committed = FlushBatchedGuestCommands();
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
WaitForCommittedCommandBuffer(committed);
WriteBuffersBackToGuest(writeBackBuffers);
}
@@ -668,7 +668,7 @@ internal static partial class MetalVideoPresenter
TagSnapshotResources(commandBuffer);
if (writeBackBuffers.Count > 0)
{
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
WaitForCommittedCommandBuffer(commandBuffer);
WriteBuffersBackToGuest(writeBackBuffers);
}
@@ -2082,6 +2082,30 @@ internal static partial class MetalVideoPresenter
}
}
/// <summary>
/// Blocks until a committed command buffer finishes. Skips the ObjC wait
/// when status is already Completed (common for tiny label/writeback
/// batches), avoiding redundant waitUntilCompleted round-trips on the
/// ordered queue.
/// </summary>
private static void WaitForCommittedCommandBuffer(nint commandBuffer)
{
if (commandBuffer == 0)
{
return;
}
// MTLCommandBufferStatusCompleted = 4.
const nint completed = 4;
if (MetalNative.Send(commandBuffer, MetalNative.Selector("status")) >= completed)
{
return;
}
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
}
private static void ReturnPooledGuestData(TranslatedGuestDraw draw)
{
foreach (var buffer in draw.GlobalMemoryBuffers)
@@ -646,6 +646,30 @@ internal static partial class MetalVideoPresenter
var pixelSize = hostWindow.PixelSize;
var width = (double)pixelSize.Width;
var height = (double)pixelSize.Height;
// Retina hosts often present at 3840x2160 into a 1920x1080 window.
// Cap is opt-in: defaulting it on silently drops present resolution for
// every Metal title. SHARPEMU_METAL_CAP_DRAWABLE=1 enables the long-edge
// cap; SHARPEMU_METAL_FULL_DRAWABLE=1 remains a no-op when the cap is off.
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_METAL_CAP_DRAWABLE"),
"1",
StringComparison.Ordinal) &&
!string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_METAL_FULL_DRAWABLE"),
"1",
StringComparison.Ordinal))
{
const double maxLongEdge = 1920.0;
var longEdge = Math.Max(width, height);
if (longEdge > maxLongEdge)
{
var scale = maxLongEdge / longEdge;
width = Math.Round(width * scale);
height = Math.Round(height * scale);
}
}
if (width == _drawableWidth && height == _drawableHeight)
{
return;
+175
View File
@@ -0,0 +1,175 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.Buffers.Binary;
using System.Text;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Ime;
public static class ImeDialogExports
{
private const int StatusNone = 0;
private const int StatusRunning = 1;
private const int StatusFinished = 2;
private const int EndStatusOk = 0;
private const ulong ParamMaxTextLengthOffset = 0x24;
private const ulong ParamInputTextBufferOffset = 0x28;
private const int ImeDialogErrorInvalidAddress = unchecked((int)0x80BC0001);
private const string DefaultInputText = "Sharp";
private static readonly object _gate = new();
private static int _status = StatusNone;
[SysAbiExport(
Nid = "NUeBrN7hzf0",
ExportName = "sceImeDialogInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogInit(CpuContext ctx)
{
var parameterAddress = ctx[CpuRegister.Rdi];
if (parameterAddress == 0)
{
return SetReturn(ctx, ImeDialogErrorInvalidAddress);
}
TryWriteInputText(ctx, parameterAddress);
lock (_gate)
{
_status = StatusFinished;
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "IADmD4tScBY",
ExportName = "sceImeDialogGetStatus",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogGetStatus(CpuContext ctx)
{
int status;
lock (_gate)
{
status = _status;
}
ctx[CpuRegister.Rax] = unchecked((ulong)(long)status);
return status;
}
[SysAbiExport(
Nid = "x01jxu+vxlc",
ExportName = "sceImeDialogGetResult",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogGetResult(CpuContext ctx)
{
var resultAddress = ctx[CpuRegister.Rdi];
if (resultAddress == 0)
{
return SetReturn(ctx, ImeDialogErrorInvalidAddress);
}
Span<byte> result = stackalloc byte[8];
result.Clear();
BinaryPrimitives.WriteInt32LittleEndian(result, EndStatusOk);
ctx.Memory.TryWrite(resultAddress, result);
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "oBmw4xrmfKs",
ExportName = "sceImeDialogAbort",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogAbort(CpuContext ctx)
{
lock (_gate)
{
_status = StatusFinished;
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "gyTyVn+bXMw",
ExportName = "sceImeDialogTerm",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogTerm(CpuContext ctx)
{
lock (_gate)
{
_status = StatusNone;
}
return SetReturn(ctx, 0);
}
private static void TryWriteInputText(CpuContext ctx, ulong parameterAddress)
{
Span<byte> field = stackalloc byte[8];
if (!ctx.Memory.TryRead(parameterAddress + ParamInputTextBufferOffset, field))
{
return;
}
var bufferAddress = BinaryPrimitives.ReadUInt64LittleEndian(field);
if (bufferAddress == 0)
{
return;
}
var maxTextLength = 16u;
Span<byte> lengthField = stackalloc byte[4];
if (ctx.Memory.TryRead(parameterAddress + ParamMaxTextLengthOffset, lengthField))
{
var declared = BinaryPrimitives.ReadUInt32LittleEndian(lengthField);
if (declared is > 0 and <= 256)
{
maxTextLength = declared;
}
}
var text = Environment.GetEnvironmentVariable("SHARPEMU_DEFAULT_PROFILE");
if (string.IsNullOrEmpty(text))
{
text = DefaultInputText;
}
if ((uint)text.Length > maxTextLength)
{
text = text[..(int)maxTextLength];
}
var encoded = Encoding.Unicode.GetBytes(text);
Span<byte> payload = stackalloc byte[encoded.Length + 2];
encoded.CopyTo(payload);
payload[^2] = 0;
payload[^1] = 0;
if (ctx.Memory.TryWrite(bufferAddress, payload))
{
Console.Error.WriteLine(
$"[LOADER][WARN] ime.dialog_autofill buf=0x{bufferAddress:X16} " +
$"max={maxTextLength} text=\"{text}\"");
}
}
private static int SetReturn(CpuContext ctx, int result)
{
ctx[CpuRegister.Rax] = unchecked((ulong)(long)result);
return result;
}
}
+31
View File
@@ -323,6 +323,37 @@ public static class JsonExports
return 0;
}
[SysAbiExport(
Nid = "wLsJlmgEIaI",
ExportName = "_ZN3sce4Json5Value10referValueERKNS0_6StringE",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int ValueReferValue(CpuContext ctx)
{
var thisAddress = ctx[CpuRegister.Rdi];
var keyStringAddress = ctx[CpuRegister.Rsi];
if (thisAddress == 0 ||
!_strings.TryGetValue(keyStringAddress, out var keyState) ||
!TryAllocateGuestObject(ctx, ValueObjectSize, out var childAddress))
{
ctx[CpuRegister.Rax] = 0;
return 0;
}
var parent = GetValue(thisAddress);
var child = parent.ValueKind == System.Text.Json.JsonValueKind.Object &&
parent.TryGetProperty(keyState.Value, out var property)
? property.Clone() : _nullElement;
StoreValue(ctx, childAddress, child);
ctx[CpuRegister.Rax] = childAddress;
TraceJsonText("Value.referValue", thisAddress, keyState.Value);
return 0;
}
[SysAbiExport(
Nid = "zTwZdI8AZ5Y",
ExportName = "_ZNK3sce4Json5Value10getBooleanEv",
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Ampr;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Media;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
@@ -97,7 +97,7 @@ public static partial class KernelMemoryCompatExports
private static readonly object _fdGate = new();
private static readonly Dictionary<int, FileStream> _openFiles = new();
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
private static readonly Dictionary<int, HostMovieBridge.BinkGuestCompletionShim>
_binkGuestCompletionShims = new();
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
@@ -1478,7 +1478,7 @@ public static partial class KernelMemoryCompatExports
}
try
{
if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath))
if (HostMovieBridge.ShouldSkipGuestMovie(hostPath))
{
LogOpenTrace(
"_open bink-skip path='" + guestPath + "' host='" + hostPath +
@@ -1489,10 +1489,10 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
HostMovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
var observedBinkMovie = false;
var useBinkCompletionShim = access == FileAccess.Read &&
Bink2MovieBridge.TryTakeOverGuestMovie(
HostMovieBridge.TryTakeOverGuestMovie(
hostPath,
out binkCompletionShim,
out observedBinkMovie);
@@ -2227,7 +2227,7 @@ public static partial class KernelMemoryCompatExports
if (notifyBinkClose)
{
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
HostMovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
}
stream.Dispose();
ctx[CpuRegister.Rax] = 0;
@@ -2256,7 +2256,7 @@ public static partial class KernelMemoryCompatExports
}
FileStream? stream;
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
HostMovieBridge.BinkGuestCompletionShim completionShim = default;
var useBinkCompletionShim = false;
lock (_fdGate)
{
@@ -2289,7 +2289,7 @@ public static partial class KernelMemoryCompatExports
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
@@ -3528,6 +3528,33 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "mkgXxsoxWHg",
ExportName = "sceKernelClearVirtualRangeName",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int KernelClearVirtualRangeName(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
var length = ctx[CpuRegister.Rsi];
lock (_memoryGate)
{
if (!TryFindVirtualQueryRegionLocked(address, findNext: false, out var region) ||
length > region.Length ||
address < region.Address ||
length > region.Address + region.Length - address)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
_mappedRegionNames.Remove(region.Address);
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "rVjRvHJ0X6c",
ExportName = "sceKernelVirtualQuery",
@@ -75,6 +75,26 @@ internal static class KernelPthreadState
return Threads.TryGetValue(threadHandle, out identity);
}
internal static bool TryGetCurrentThreadIdentity(
out ulong threadHandle,
out ThreadIdentity identity)
{
threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
{
return true;
}
threadHandle = _currentThreadHandle;
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
{
return true;
}
identity = default;
return false;
}
private static ThreadIdentity EnsureGuestThreadIdentity(ulong guestThreadHandle)
{
if (Threads.TryGetValue(guestThreadHandle, out var existing))
@@ -0,0 +1,76 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Kernel;
/// <summary>
/// Shared formatting for opt-in kernel synchronization diagnostics.
/// Callers must gate this behind their trace flag so normal synchronization
/// paths do not allocate strings or walk guest frame chains.
/// </summary>
internal static class KernelSyncTraceFormatter
{
internal static string FormatContext(CpuContext ctx)
{
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
var threadName = identity.Name ?? "<unknown>";
var returnRip = GuestThreadExecution.TryGetCurrentImportCallFrame(out var importFrame)
? importFrame.ReturnRip
: TryReadReturnRip(ctx);
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
$"managed={Environment.CurrentManagedThreadId} ret=0x{returnRip:X16} " +
$"frames={FormatFrameChain(ctx)}";
}
internal static string FormatCurrentThread()
{
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
var threadName = identity.Name ?? Thread.CurrentThread.Name ?? "<unknown>";
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
$"managed={Environment.CurrentManagedThreadId}";
}
internal static string FormatFrameChain(CpuContext ctx)
{
Span<ulong> returns = stackalloc ulong[4];
var count = 0;
var frame = ctx[CpuRegister.Rbp];
while (count < returns.Length && frame != 0)
{
if (!ctx.TryReadUInt64(frame, out var nextFrame) ||
!ctx.TryReadUInt64(frame + sizeof(ulong), out var returnAddress))
{
break;
}
returns[count++] = returnAddress;
if (nextFrame <= frame || nextFrame - frame > 0x100000)
{
break;
}
frame = nextFrame;
}
return count switch
{
0 => "none",
1 => $"0x{returns[0]:X16}",
2 => $"0x{returns[0]:X16},0x{returns[1]:X16}",
3 => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16}",
_ => $"0x{returns[0]:X16},0x{returns[1]:X16}," +
$"0x{returns[2]:X16},0x{returns[3]:X16}",
};
}
private static ulong TryReadReturnRip(CpuContext ctx)
{
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var returnRip);
return returnRip;
}
}
@@ -0,0 +1,489 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.IO;
using System.Threading;
using FFmpeg.AutoGen;
namespace SharpEmu.Libs.Media;
internal sealed unsafe class FfmpegMediaStream : Stream
{
internal const int AudioSampleRate = 48000;
internal const int AudioChannels = 2;
private readonly object _decodeGate = new();
private readonly bool _isVideo;
private readonly int _videoWidth;
private readonly int _videoHeight;
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
private AVFrame* _frame;
private AVPacket* _packet;
private SwsContext* _swsContext;
private SwrContext* _swrContext;
private int _streamIndex;
private byte[] _pending = [];
private int _pendingOffset;
private bool _draining;
private bool _finished;
private int _disposed;
private FfmpegMediaStream(bool isVideo, int width, int height)
{
_isVideo = isVideo;
_videoWidth = width;
_videoHeight = height;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
internal static bool TryOpenVideo(
string path,
int width,
int height,
out FfmpegMediaStream? stream) =>
TryOpen(path, AVMediaType.AVMEDIA_TYPE_VIDEO, width, height, out stream);
internal static bool TryOpenAudio(string path, out FfmpegMediaStream? stream) =>
TryOpen(path, AVMediaType.AVMEDIA_TYPE_AUDIO, 0, 0, out stream);
private static bool TryOpen(
string path,
AVMediaType mediaType,
int width,
int height,
out FfmpegMediaStream? stream)
{
stream = null;
FfmpegRuntime.EnsureInitialized();
var candidate = new FfmpegMediaStream(mediaType == AVMediaType.AVMEDIA_TYPE_VIDEO, width, height);
AVFormatContext* formatContext = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
{
return false;
}
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
{
ffmpeg.avformat_close_input(&formatContext);
return false;
}
AVCodec* decoder = null;
var streamIndex = ffmpeg.av_find_best_stream(formatContext, mediaType, -1, -1, &decoder, 0);
if (streamIndex < 0 || decoder is null)
{
ffmpeg.avformat_close_input(&formatContext);
return false;
}
var codecContext = ffmpeg.avcodec_alloc_context3(decoder);
if (codecContext is null ||
ffmpeg.avcodec_parameters_to_context(
codecContext,
formatContext->streams[streamIndex]->codecpar) < 0 ||
ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
{
if (codecContext is not null)
{
ffmpeg.avcodec_free_context(&codecContext);
}
ffmpeg.avformat_close_input(&formatContext);
return false;
}
candidate._formatContext = formatContext;
candidate._codecContext = codecContext;
candidate._streamIndex = streamIndex;
candidate._frame = ffmpeg.av_frame_alloc();
candidate._packet = ffmpeg.av_packet_alloc();
if (candidate._frame is null || candidate._packet is null)
{
candidate.Dispose();
return false;
}
stream = candidate;
return true;
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] in-process decoder failed to open '{path}': {exception.Message}");
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
}
candidate.Dispose();
return false;
}
}
internal static bool TryProbe(
string path,
out int width,
out int height,
out double frameRate,
out double durationSeconds)
{
width = 0;
height = 0;
frameRate = 0;
durationSeconds = 0;
FfmpegRuntime.EnsureInitialized();
AVFormatContext* formatContext = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
{
return false;
}
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
{
return false;
}
var streamIndex = ffmpeg.av_find_best_stream(
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, null, 0);
if (streamIndex < 0)
{
return false;
}
var stream = formatContext->streams[streamIndex];
width = stream->codecpar->width;
height = stream->codecpar->height;
var rate = stream->avg_frame_rate;
if (rate.den > 0 && rate.num > 0)
{
frameRate = (double)rate.num / rate.den;
}
if (stream->duration > 0 && stream->time_base.den > 0)
{
durationSeconds = stream->duration *
((double)stream->time_base.num / stream->time_base.den);
}
else if (formatContext->duration > 0)
{
durationSeconds = (double)formatContext->duration / ffmpeg.AV_TIME_BASE;
}
return width > 0 && height > 0;
}
finally
{
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
}
}
}
public override int Read(byte[] buffer, int offset, int count) =>
Read(buffer.AsSpan(offset, count));
public override int Read(Span<byte> buffer)
{
if (buffer.IsEmpty)
{
return 0;
}
lock (_decodeGate)
{
if (Volatile.Read(ref _disposed) != 0)
{
return 0;
}
var written = 0;
while (written < buffer.Length)
{
if (_pendingOffset >= _pending.Length)
{
if (_finished || !TryDecodeIntoPending())
{
break;
}
continue;
}
var available = _pending.Length - _pendingOffset;
var take = Math.Min(available, buffer.Length - written);
_pending.AsSpan(_pendingOffset, take).CopyTo(buffer[written..]);
_pendingOffset += take;
written += take;
}
return written;
}
}
private bool TryDecodeIntoPending()
{
if (!TryReceiveFrame())
{
_finished = true;
return false;
}
var produced = _isVideo ? ConvertVideoFrame() : ConvertAudioFrame();
ffmpeg.av_frame_unref(_frame);
if (produced is null)
{
_finished = true;
return false;
}
_pending = produced;
_pendingOffset = 0;
return true;
}
private byte[]? ConvertVideoFrame()
{
var width = _videoWidth > 0 ? _videoWidth : _frame->width;
var height = _videoHeight > 0 ? _videoHeight : _frame->height;
if (width <= 0 || height <= 0)
{
return null;
}
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->width,
_frame->height,
(AVPixelFormat)_frame->format,
width,
height,
AVPixelFormat.AV_PIX_FMT_NV12,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
return null;
}
var lumaBytes = width * height;
var output = new byte[lumaBytes + lumaBytes / 2];
fixed (byte* outputPointer = output)
{
var planes = new byte*[4] { outputPointer, outputPointer + lumaBytes, null, null };
var strides = new int[4] { width, width, 0, 0 };
var rows = ffmpeg.sws_scale(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->height,
planes,
strides);
return rows == height ? output : null;
}
}
private byte[]? ConvertAudioFrame()
{
var outputLayout = new AVChannelLayout();
ffmpeg.av_channel_layout_default(&outputLayout, AudioChannels);
var inputLayout = _frame->ch_layout;
SwrContext* swrContext = _swrContext;
var configureResult = ffmpeg.swr_alloc_set_opts2(
&swrContext,
&outputLayout,
AVSampleFormat.AV_SAMPLE_FMT_S16,
AudioSampleRate,
&inputLayout,
(AVSampleFormat)_frame->format,
_frame->sample_rate,
0,
null);
_swrContext = swrContext;
if (configureResult < 0 || _swrContext is null)
{
return null;
}
if (ffmpeg.swr_is_initialized(_swrContext) == 0 && ffmpeg.swr_init(_swrContext) < 0)
{
return null;
}
var maxSamples = (int)ffmpeg.av_rescale_rnd(
ffmpeg.swr_get_delay(_swrContext, _frame->sample_rate) + _frame->nb_samples,
AudioSampleRate,
_frame->sample_rate,
AVRounding.AV_ROUND_UP);
if (maxSamples <= 0)
{
return null;
}
var output = new byte[maxSamples * AudioChannels * sizeof(short)];
fixed (byte* outputPointer = output)
{
var planes = stackalloc byte*[1];
planes[0] = outputPointer;
var converted = ffmpeg.swr_convert(
_swrContext,
planes,
maxSamples,
_frame->extended_data,
_frame->nb_samples);
if (converted < 0)
{
return null;
}
var byteCount = converted * AudioChannels * sizeof(short);
if (byteCount == output.Length)
{
return output;
}
var trimmed = new byte[byteCount];
output.AsSpan(0, byteCount).CopyTo(trimmed);
return trimmed;
}
}
private bool TryReceiveFrame()
{
while (true)
{
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
if (receiveResult >= 0)
{
return true;
}
if (receiveResult == ffmpeg.AVERROR_EOF ||
receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
_draining)
{
return false;
}
if (!TryFeedPacket())
{
return false;
}
}
}
private bool TryFeedPacket()
{
while (true)
{
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
if (readResult < 0)
{
_draining = true;
return ffmpeg.avcodec_send_packet(_codecContext, null) >= 0;
}
if (_packet->stream_index != _streamIndex)
{
ffmpeg.av_packet_unref(_packet);
continue;
}
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
ffmpeg.av_packet_unref(_packet);
return sendResult >= 0;
}
}
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
base.Dispose(disposing);
return;
}
lock (_decodeGate)
{
if (_swsContext is not null)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
if (_swrContext is not null)
{
var swrContext = _swrContext;
ffmpeg.swr_free(&swrContext);
_swrContext = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
}
}
base.Dispose(disposing);
}
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.IO;
using FFmpeg.AutoGen;
namespace SharpEmu.Libs.Media;
internal static class FfmpegRuntime
{
private static readonly object _gate = new();
private static bool _initialized;
internal static void EnsureInitialized()
{
if (_initialized)
{
return;
}
lock (_gate)
{
if (_initialized)
{
return;
}
_initialized = true;
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
DynamicallyLoadedBindings.Initialize();
}
}
}
@@ -5,7 +5,7 @@ using System.Buffers;
using FFmpeg.AutoGen;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
namespace SharpEmu.Libs.Media;
/// <summary>
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
@@ -13,7 +13,7 @@ namespace SharpEmu.Libs.Bink;
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
/// bridge of our own to build. See docs/bink2-bridge.md.
/// </summary>
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
{
private const int OutputAudioChannels = 2;
private const int OutputAudioBytesPerSample = sizeof(short);
@@ -48,7 +48,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
public uint FramesPerSecondDenominator { get; }
private FfmpegNativeBinkFrameSource(
private FfmpegVideoDecoder(
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
@@ -77,43 +77,14 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
_packet = ffmpeg.av_packet_alloc();
}
private static bool _rootPathInitialized;
/// <summary>
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
/// downloads next to the executable (see SharpEmu.CLI.csproj's
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
/// in the single-file bundle so the OS loader can resolve the normal
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
/// </summary>
private static void EnsureRootPathInitialized()
{
if (_rootPathInitialized)
{
return;
}
_rootPathInitialized = true;
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
// "plugins" subfolder next to the executable rather than flat beside
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
// itself, but that constructor fires on first touch of the ffmpeg type --
// which is the RootPath assignment above -- so it binds against the
// default (empty) RootPath before the assignment's own setter body runs.
// Every function resolved during that first pass permanently throws
// NotSupportedException. Re-running Initialize() now, with RootPath
// actually set, rebinds everything against the real search path.
DynamicallyLoadedBindings.Initialize();
}
private static void EnsureRootPathInitialized() =>
SharpEmu.Libs.Media.FfmpegRuntime.EnsureInitialized();
internal static bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
out FfmpegNativeBinkFrameSource? source)
out FfmpegVideoDecoder? source)
{
source = null;
EnsureRootPathInitialized();
@@ -222,7 +193,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
outputHeight = Math.Max(1, outputHeight);
}
source = new FfmpegNativeBinkFrameSource(
source = new FfmpegVideoDecoder(
formatContext,
codecContext,
videoStreamIndex,
@@ -4,29 +4,44 @@
using System.Runtime.InteropServices;
using System.Buffers.Binary;
namespace SharpEmu.Libs.Bink;
namespace SharpEmu.Libs.Media;
/// <summary>
/// Optional host-side Bink 2 bridge for games that ship a static Bink player.
/// Host-side movie bridge for games that decode video inside their own
/// executable instead of going through an HLE decoder.
///
/// The game in that case never imports libSceVideodec, so an HLE video-decoder
/// export cannot see its movie frames. Kernel file opens identify the active
/// .bk2 file and the presenter requests BGRA frames from a tiny native adapter.
/// The adapter is deliberately a separate, user-supplied library: Bink 2 is a
/// proprietary SDK and SharpEmu must neither bundle it nor depend on its ABI.
/// Such a game never imports libSceVideodec or sceAvPlayer, so no HLE export
/// can see its movie frames. Kernel file opens identify the active movie and
/// the presenter requests BGRA frames from <see cref="FfmpegVideoDecoder"/> —
/// the same decoder sceAvPlayer uses, so every format is handled in one place.
/// </summary>
internal static class Bink2MovieBridge
internal static class HostMovieBridge
{
private const uint MaxDimension = 16384;
private const uint MaxHostVideoWidth = 1920;
private const uint MaxHostVideoHeight = 1080;
private static readonly string[] SelfDecodedMovieExtensions = [".bk2"];
private static bool IsSelfDecodedMovie(string hostPath)
{
foreach (var extension in SelfDecodedMovieExtensions)
{
if (hostPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static readonly object Gate = new();
private static string? _activePath;
private static Bink2MovieInfo _activeInfo;
private static byte[]? _frameBuffer;
private static bool _frameBufferPresented;
private static BinkFramePlayback? _playback;
private static MediaFramePlayback? _playback;
private static long _frameSerial;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
@@ -62,7 +77,7 @@ internal static class Bink2MovieBridge
/// statically linked into its executable.
/// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
IsSelfDecodedMovie(hostPath) &&
ResolveMode() == MovieMode.Skip;
/// <summary>
@@ -71,8 +86,7 @@ internal static class Bink2MovieBridge
/// </summary>
internal static bool ObserveGuestMovie(string hostPath)
{
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
if (!IsSelfDecodedMovie(hostPath) || !File.Exists(hostPath))
{
return false;
}
@@ -186,9 +200,6 @@ internal static class Bink2MovieBridge
case MovieMode.Dummy:
AttachDummyMovieLocked(hostPath);
return;
case MovieMode.Ffmpeg:
AttachFfmpegMovieLocked(hostPath);
return;
case MovieMode.Native:
AttachNativeMovieLocked(hostPath);
return;
@@ -197,7 +208,7 @@ internal static class Bink2MovieBridge
private static void AttachNativeMovieLocked(string hostPath)
{
if (!FfmpegNativeBinkFrameSource.TryOpen(
if (!FfmpegVideoDecoder.TryOpen(
hostPath, _presentationWidth, _presentationHeight, out var source) ||
source is null)
{
@@ -250,14 +261,13 @@ internal static class Bink2MovieBridge
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Ffmpeg;
return MovieMode.Native;
}
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
// gracefully (falls back to the guest's own decode, logging one
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
// downloads next to the executable are genuinely unavailable, so
// defaulting to Native unconditionally is safe.
// Native is the default: FfmpegVideoDecoder.TryOpen degrades gracefully
// (falls back to the guest's own decode, logging one informational line)
// if the FFmpeg libraries SharpEmu.CLI.csproj downloads next to the
// executable are genuinely unavailable, so defaulting to it is safe.
return MovieMode.Native;
}
@@ -282,43 +292,15 @@ internal static class Bink2MovieBridge
info.Width + "x" + info.Height + ".");
}
private static void AttachFfmpegMovieLocked(string hostPath)
{
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
Path.GetFileName(hostPath));
return;
}
if (!FfmpegBinkFrameSource.TryOpen(
hostPath,
info.Width,
info.Height,
info.FramesPerSecondNumerator,
info.FramesPerSecondDenominator,
out var source) || source is null)
{
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink FFmpeg source attached: " +
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static void AttachPlaybackLocked(
string hostPath,
Bink2MovieInfo info,
IBinkFrameDecoder decoder)
IMediaFrameDecoder decoder)
{
CloseActiveLocked();
_activePath = hostPath;
_activeInfo = info;
_playback = new BinkFramePlayback(decoder);
_playback = new MediaFramePlayback(decoder);
}
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
@@ -406,7 +388,6 @@ internal static class Bink2MovieBridge
Skip,
Dummy,
Native,
Ffmpeg,
}
private static readonly Queue<string> PendingMoviePaths = new();
@@ -4,9 +4,9 @@
using System.Diagnostics;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
namespace SharpEmu.Libs.Media;
internal interface IBinkFrameDecoder : IDisposable
internal interface IMediaFrameDecoder : IDisposable
{
uint Width { get; }
@@ -23,12 +23,12 @@ internal interface IBinkFrameDecoder : IDisposable
/// Keeps blocking codec work away from the Vulkan presentation thread and
/// releases decoded frames according to the movie time base.
/// </summary>
internal sealed class BinkFramePlayback : IDisposable
internal sealed class MediaFramePlayback : IDisposable
{
private const int BufferCount = 5;
private readonly object _gate = new();
private readonly IBinkFrameDecoder _decoder;
private readonly IMediaFrameDecoder _decoder;
private readonly Queue<byte[]> _freeBuffers = new();
private readonly Queue<DecodedFrame> _decodedFrames = new();
private readonly Thread _decoderThread;
@@ -45,7 +45,7 @@ internal sealed class BinkFramePlayback : IDisposable
private bool _finished;
private int _disposed;
internal BinkFramePlayback(IBinkFrameDecoder decoder)
internal MediaFramePlayback(IMediaFrameDecoder decoder)
{
_decoder = decoder;
Width = decoder.Width;
+36
View File
@@ -373,6 +373,42 @@ public static class PadExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
}
// Size taken from the caller's own frame rather than assumed: the guest
// reserves 0x10 bytes, points the out-param at rbp-0x30, and stores its
// stack cookie at rbp-0x28, so only eight bytes belong to the state. A
// sixteen-byte write would land on the cookie and fail the stack check.
private const int TriggerEffectStateSize = 8;
[SysAbiExport(
Nid = "znaWI0gpuo8",
ExportName = "scePadGetTriggerEffectState",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadGetTriggerEffectState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var stateAddress = ctx[CpuRegister.Rsi];
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
if (stateAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// No host pad exposes DualSense adaptive-trigger feedback, so every
// trigger reports the neutral "no effect engaged" state. Reporting it
// as success is what lets the caller take its normal path instead of
// falling back to a cached button bitmask every poll.
Span<byte> state = stackalloc byte[TriggerEffectStateSize];
state.Clear();
return ctx.Memory.TryWrite(stateAddress, state)
? ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
{
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
@@ -33,12 +33,17 @@ public static class SaveDataDialogExports
LibraryName = "libSceSaveDataDialog")]
public static int SaveDataDialogInitialize(CpuContext ctx)
{
if (Interlocked.CompareExchange(ref _status, StatusInitialized, StatusNone) != StatusNone)
var previous = Interlocked.CompareExchange(
ref _status,
StatusInitialized,
StatusNone);
if (previous == StatusNone || previous == StatusFinished)
{
return ctx.SetReturn(ErrorAlreadyInitialized);
Interlocked.Exchange(ref _status, StatusInitialized);
return ctx.SetReturn(ErrorOk);
}
return ctx.SetReturn(ErrorOk);
return ctx.SetReturn(ErrorAlreadyInitialized);
}
[SysAbiExport(
+1
View File
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ItemGroup>
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
<InternalsVisibleTo Include="SharpEmu.Core" />
</ItemGroup>
<ItemGroup>
@@ -144,16 +144,6 @@ public static class UserServiceExports
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// Title-captured alias NID for the same username query.
#pragma warning disable SHEM004
[SysAbiExport(
Nid = "znaWI0gpuo8",
ExportName = "sceUserServiceGetUserName",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceUserService")]
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
#pragma warning restore SHEM004
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
#pragma warning disable SHEM006
[SysAbiExport(
+58 -29
View File
@@ -30,6 +30,7 @@ public static class PerfOverlay
StringComparison.Ordinal);
private static long _lastPresentTimestamp;
private static long _lastSubmitTimestamp;
private static long _sessionStartTimestamp;
private static readonly double[] _frameMilliseconds = new double[FrameHistorySize];
private static int _frameHistoryIndex;
@@ -40,8 +41,11 @@ public static class PerfOverlay
// Refreshed once per second so per-frame fills never allocate.
private static long _statsWindowStart = Stopwatch.GetTimestamp();
// Headline FPS tracks guest VideoOut flips (RecordSubmit), not host
// swapchain presents. Metal's free-running present timer hits ~120 Hz on
// ProMotion even while the guest is stalled on GPU waits.
private static double _fps;
private static double _submittedFps;
private static double _presentFps;
private static double _drawsPerSecond;
private static double _averageFrameMs;
private static double _allocatedMbPerSecond;
@@ -64,24 +68,29 @@ public static class PerfOverlay
public static void Toggle() => _enabled = !_enabled;
/// <summary>Called by the presenter after each successful present.</summary>
/// <summary>Called by the presenter after each successful host present.</summary>
public static void RecordPresent()
{
var now = Stopwatch.GetTimestamp();
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
var last = _lastPresentTimestamp;
_lastPresentTimestamp = now;
Interlocked.CompareExchange(ref _sessionStartTimestamp, Stopwatch.GetTimestamp(), 0);
Interlocked.Increment(ref _presentedInWindow);
if (last != 0)
{
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
}
_lastPresentTimestamp = Stopwatch.GetTimestamp();
}
/// <summary>Called on every guest flip submission.</summary>
public static void RecordSubmit() => Interlocked.Increment(ref _submittedInWindow);
public static void RecordSubmit()
{
var now = Stopwatch.GetTimestamp();
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
Interlocked.Increment(ref _submittedInWindow);
var last = Interlocked.Exchange(ref _lastSubmitTimestamp, now);
if (last != 0)
{
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
var index = _frameHistoryIndex;
_frameMilliseconds[index] = milliseconds;
_frameHistoryIndex = (index + 1) % FrameHistorySize;
}
}
/// <summary>Called per translated draw/dispatch executed.</summary>
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
@@ -128,27 +137,47 @@ public static class PerfOverlay
{
var seconds = (double)elapsedTicks / Stopwatch.Frequency;
_statsWindowStart = now;
_fps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
_submittedFps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
_fps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
_presentFps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
_drawsPerSecond = Interlocked.Exchange(ref _drawsInWindow, 0) / seconds;
double totalMs = 0;
var samples = 0;
foreach (var ms in _frameMilliseconds)
{
if (ms > 0)
{
totalMs += ms;
samples++;
}
}
_averageFrameMs = samples > 0 ? totalMs / samples : 0;
var allocated = GC.GetTotalAllocatedBytes(precise: false);
_allocatedMbPerSecond = (allocated - _lastAllocatedBytes) / seconds / (1024.0 * 1024.0);
_lastAllocatedBytes = allocated;
// Headline MS must track *current* guest cadence. Averaging the whole
// 128-slot history kept a single 8s boot gap on screen for minutes
// (FPS 0 + 8000 MS) even after the stall ended.
var lastSubmit = Interlocked.Read(ref _lastSubmitTimestamp);
string msLabel;
if (_fps > 0)
{
var lastIndex = (_frameHistoryIndex - 1 + FrameHistorySize) % FrameHistorySize;
var lastInterval = _frameMilliseconds[lastIndex];
_averageFrameMs = lastInterval > 0 ? lastInterval : 1000.0 / _fps;
msLabel = $"{_averageFrameMs:0.0} MS";
}
else if (lastSubmit != 0)
{
// No guest flips this window: show stall age, but label it so it
// is not read as "the game is rendering 20s frames" during boot
// asset load (ALLOC high, DRAWS 0 after splash).
_averageFrameMs = (now - lastSubmit) * 1000.0 / Stopwatch.Frequency;
msLabel = _allocatedMbPerSecond > 50.0
? $"LOAD {_averageFrameMs / 1000.0:0.0}S"
: $"STALL {_averageFrameMs / 1000.0:0.0}S";
}
else if (_presentFps > 0)
{
_averageFrameMs = 1000.0 / _presentFps;
msLabel = $"{_averageFrameMs:0.0} MS";
}
else
{
_averageFrameMs = 0;
msLabel = "0.0 MS";
}
var gen0 = GC.CollectionCount(0);
var gen1 = GC.CollectionCount(1);
var gen2 = GC.CollectionCount(2);
@@ -174,7 +203,7 @@ public static class PerfOverlay
var elapsedHours = elapsedSeconds / 3600;
var elapsedMinutes = elapsedSeconds / 60 % 60;
var elapsedRemainingSeconds = elapsedSeconds % 60;
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
_line1 = $"FPS {_fps:0.0} PRES {_presentFps:0.0} {msLabel}";
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024);
@@ -5,7 +5,7 @@ using Silk.NET.Core;
using Silk.NET.Core.Native;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
@@ -1046,14 +1046,50 @@ internal static unsafe class VulkanVideoPresenter
targets.Count > 8 ||
AnyRenderTargetInvalid(targets))
{
var dropCount = Interlocked.Increment(ref _offscreenDropTraceCount);
if (dropCount <= 16 || dropCount % 500 == 0)
{
var detail = targets.Count == 0
? "<no targets>"
: string.Join(
" ",
targets.Select(t => $"0x{t.Address:X}:{t.Width}x{t.Height}"));
Console.Error.WriteLine(
$"[LOADER][WARN] vk.offscreen_drop#{dropCount} spirv={pixelSpirv.Length} " +
$"mrt={targets.Count} vs=0x{shaderAddress:X16} {detail}");
}
return;
}
var firstTarget = targets[0];
if (RenderTargetsMismatchedOrAliased(targets, firstTarget))
{
Console.Error.WriteLine(
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched dimensions or aliased targets.");
var skipCount = Interlocked.Increment(ref _mrtSkipTraceCount);
if (skipCount <= 16 || skipCount % 200 == 0)
{
var aliased = false;
for (var i = 0; i < targets.Count && !aliased; i++)
{
for (var j = i + 1; j < targets.Count; j++)
{
if (targets[i].Address == targets[j].Address)
{
aliased = true;
break;
}
}
}
var detail = string.Join(
" ",
targets.Select(t =>
$"0x{t.Address:X}:{t.Width}x{t.Height}:f{t.Format}/{t.NumberType}"));
Console.Error.WriteLine(
$"[LOADER][WARN] vk.mrt_skip#{skipCount} mrt={targets.Count} " +
$"aliased={aliased} vs=0x{shaderAddress:X16} {detail}");
}
return;
}
@@ -1298,6 +1334,8 @@ internal static unsafe class VulkanVideoPresenter
}
}
private static long _mrtSkipTraceCount;
private static long _offscreenDropTraceCount;
private static long _perfDrawCount;
private static long _perfDrawTicks;
private static long _perfPipelineCreations;
@@ -1739,6 +1777,57 @@ internal static unsafe class VulkanVideoPresenter
uint depth) =>
checked(GetGuestImageByteCount(format, width, height) * Math.Max(depth, 1u));
/// <summary>
/// Upper bound on the backing extent that guest CPU-write tracking is
/// armed over. Deliberately equal to the presenter-side re-upload budget
/// used by the AGC flip/acquire sync path: arming a range larger than the
/// sync path is willing to read back would fault and dirty forever without
/// ever producing a re-upload, so it is pure cost.
/// </summary>
internal const ulong MaxTrackedGuestImageBytes = 128UL * 1024UL * 1024UL;
/// <summary>
/// Decides whether a guest surface is eligible for CPU-write tracking.
/// The predicate is byte-based on purpose: the cost that actually scales
/// with surface size is the dirty re-upload (one allocation plus a guest
/// memory read of the whole extent per dirty flip), not the arming itself
/// (one mprotect and, per write burst, one fault for the whole range).
/// A resolution cap was the wrong proxy — it ignored bytes-per-texel and
/// volume depth while excluding the 4K UI sheets that most need
/// invalidation.
/// </summary>
internal static bool ShouldTrackGuestImageWrites(ulong byteCount) =>
byteCount != 0 && byteCount <= MaxTrackedGuestImageBytes;
/// <summary>
/// Decides whether a sampled guest image whose backing memory the parse
/// thread just re-read should be re-uploaded from those bytes.
/// <para>
/// <paramref name="isCpuBacked"/> is a latch that flips false the first
/// time an address is used as a render target and never flips back, so it
/// cannot be the sole gate: a font atlas or UI sheet that was also
/// rendered into is permanently frozen at its first upload afterwards.
/// The write tracker answers the real question. A parse-time generation
/// above zero means a guest CPU store was observed on the backing range,
/// and a generation the last upload does not already cover means those
/// bytes are newer than the host image.
/// </para>
/// <para>
/// Requiring a positive generation keeps the pure GPU-feedback case
/// (render into an image, then sample it) safe: such a surface is tracked
/// but never CPU-written, so its generation stays zero and the live image
/// is preserved instead of being overwritten with guest memory.
/// </para>
/// </summary>
internal static bool ShouldRefreshGuestImageFromCpu(
bool isCpuBacked,
long textureWriteGeneration,
bool hasUploadedGeneration,
long uploadedGeneration) =>
isCpuBacked ||
(textureWriteGeneration > 0 &&
(!hasUploadedGeneration || uploadedGeneration != textureWriteGeneration));
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
// or Undefined when no counterpart exists. Used to encode linear-float
// guest flips on their way into a UNORM swapchain.
@@ -1754,6 +1843,28 @@ internal static unsafe class VulkanVideoPresenter
internal static bool IsLinearFloatPresentSource(Format format) =>
format is Format.R16G16B16A16Sfloat or Format.R32G32B32A32Sfloat;
// A guest image accepts a request in a different Vulkan format without
// being recreated when the two formats are the same texel layout read
// through different transfer functions (sRGB vs UNORM counterparts).
// Both must also be legal alias views of each other so the shared
// mutable-format image can serve either identity. Same-class numeric
// reinterpretation (R32Uint over R8G8B8A8Unorm, packed 10:10:10:2 over
// 8:8:8:8) is excluded: the attachment keeps the existing image's
// format, and a fragment shader translated for the other numeric type
// would no longer match it.
internal static bool IsAliasableGuestImageFormat(
Format existingFormat,
Format requestedFormat) =>
existingFormat != requestedFormat &&
Presenter.IsCompatibleViewFormat(existingFormat, requestedFormat) &&
Presenter.GetStorageImageFormat(existingFormat) ==
Presenter.GetStorageImageFormat(requestedFormat);
internal static bool IsCompatibleGuestImageViewFormat(
Format imageFormat,
Format viewFormat) =>
Presenter.IsCompatibleViewFormat(imageFormat, viewFormat);
private static byte[]? TakeGuestImageInitialData(ulong address)
{
lock (_gate)
@@ -1834,8 +1945,22 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
availableFormat == guestFormat;
if (!_availableGuestImages.TryGetValue(address, out var availableFormat))
{
return false;
}
if (availableFormat == guestFormat)
{
return true;
}
return TryDecodeRenderTargetFormat(
(availableFormat >> 8) & 0x1FFu,
availableFormat & 0xFFu,
out var resident) &&
TryDecodeRenderTargetFormat(format, numberType, out var requested) &&
Presenter.IsCompatibleViewFormat(resident.Format, requested.Format);
}
}
@@ -3194,6 +3319,11 @@ internal static unsafe class VulkanVideoPresenter
private readonly VulkanHostBufferPool _hostBufferPool;
private readonly List<GuestBufferAllocation> _guestBufferAllocations = [];
private readonly Queue<PendingGuestSubmission> _pendingGuestSubmissions = new();
// Submissions whose fence timed out. Keep GPU objects alive until the
// fence signals (or the device is lost) so a single hung compute
// dispatch cannot re-block every subsequent capacity wait for the full
// fence timeout (~3s → ~0.3 FPS).
private readonly Queue<PendingGuestSubmission> _abandonedGuestSubmissions = new();
private readonly Dictionary<string, ulong> _lastSubmittedTimelineByGuestQueue =
new(StringComparer.Ordinal);
private readonly Stack<DescriptorPool> _recycledDescriptorPools = new();
@@ -4079,6 +4209,7 @@ internal static unsafe class VulkanVideoPresenter
ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats,
ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat,
ShaderStorageImageWriteWithoutFormat = supportedFeatures.ShaderStorageImageWriteWithoutFormat,
TextureCompressionBC = supportedFeatures.TextureCompressionBC,
RobustBufferAccess = supportedFeatures.RobustBufferAccess,
};
@@ -4118,6 +4249,13 @@ internal static unsafe class VulkanVideoPresenter
"translated shaders using unformatted storage image load/store will fail.");
}
if (!supportedFeatures.TextureCompressionBC)
{
Console.Error.WriteLine(
"[LOADER][WARN] GPU does not support textureCompressionBC " +
"guest BC1-BC7 textures cannot be sampled directly.");
}
var maintenance8Features = new PhysicalDeviceMaintenance8FeaturesKHR
{
SType = StructureType.PhysicalDeviceMaintenance8FeaturesKhr,
@@ -4452,7 +4590,7 @@ internal static unsafe class VulkanVideoPresenter
_swapchainFormat = surfaceFormat.Format;
_swapchainColorSpace = surfaceFormat.ColorSpace;
_extent = ChooseExtent(capabilities);
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
HostMovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
var presentMode = ChoosePresentMode();
var imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount != 0)
@@ -5148,6 +5286,45 @@ internal static unsafe class VulkanVideoPresenter
{
CollectCompletedGuestSubmissions(waitForOldest: true);
}
while (_abandonedGuestSubmissions.Count != 0)
{
CollectAbandonedGuestSubmissions();
if (_abandonedGuestSubmissions.Count == 0)
{
break;
}
if (!_abandonedGuestSubmissions.TryPeek(out var oldest))
{
break;
}
var fence = oldest.Fence;
var result = _vk.WaitForFences(
_device,
1,
&fence,
true,
_guestFenceWaitTimeoutNs);
if (result == Result.Timeout || result == Result.ErrorDeviceLost)
{
if (result == Result.ErrorDeviceLost)
{
_deviceLost = true;
}
while (_abandonedGuestSubmissions.TryDequeue(out var abandoned))
{
RetireGuestSubmission(abandoned);
}
break;
}
Check(result, $"vkWaitForFences(abandoned: {oldest.DebugName})");
CollectAbandonedGuestSubmissions();
}
}
private void CollectCompletedGuestSubmissions(bool waitForOldest, ulong maxWaitNs = 0)
@@ -5175,18 +5352,26 @@ internal static unsafe class VulkanVideoPresenter
// would otherwise block the render thread forever, starving
// the swapchain present (black screen). Log the culprit and
// continue so at least the last good frame can be shown.
if (!isProbeWait && _tracedFenceTimeouts.Add(oldest.DebugName))
if (isProbeWait)
{
return;
}
if (_tracedFenceTimeouts.Add(oldest.DebugName))
{
Console.Error.WriteLine(
$"[LOADER][WARN] vk.fence_wait_timeout submission='{oldest.DebugName}' " +
$"— GPU work not completing after {_guestFenceWaitTimeoutNs / 1_000_000}ms; " +
"render thread continuing (present not blocked).");
"abandoning in-flight tracking so later frames are not re-blocked.");
}
return;
// Move out of the blocking queue without destroying GPU
// objects yet — the work may still be running. Poll and
// retire from the abandoned list once the fence signals.
_pendingGuestSubmissions.Dequeue();
_abandonedGuestSubmissions.Enqueue(oldest);
}
if (result == Result.ErrorDeviceLost)
else if (result == Result.ErrorDeviceLost)
{
_deviceLost = true;
if (!_deviceLostLogged)
@@ -5227,42 +5412,77 @@ internal static unsafe class VulkanVideoPresenter
}
_pendingGuestSubmissions.Dequeue();
RetireGuestSubmission(submission);
}
if (!_deviceLost)
CollectAbandonedGuestSubmissions();
ProcessDeferredTextureDestroys();
}
private void CollectAbandonedGuestSubmissions()
{
var pending = _abandonedGuestSubmissions.Count;
for (var i = 0; i < pending; i++)
{
if (!_abandonedGuestSubmissions.TryDequeue(out var submission))
{
foreach (var image in submission.TraceImages)
{
TraceGuestImageContents(image);
}
break;
}
foreach (var resources in submission.Resources)
var status = _vk.GetFenceStatus(_device, submission.Fence);
if (status == Result.NotReady && !_deviceLost)
{
DestroyTranslatedDrawResources(resources);
_abandonedGuestSubmissions.Enqueue(submission);
continue;
}
foreach (var (buffer, memory) in submission.RetireBuffers)
if (status == Result.ErrorDeviceLost)
{
_vk.DestroyBuffer(_device, buffer, null);
_vk.FreeMemory(_device, memory, null);
_deviceLost = true;
}
else if (status != Result.NotReady && status != Result.Success)
{
Check(status, $"vkGetFenceStatus(abandoned: {submission.DebugName})");
}
// The fence has signalled, so the detile dispatch that used these
// is done reading them; hand them back for the next texture.
foreach (var transients in submission.RetireDetile)
{
_detilePass?.Retire(transients);
}
RetireGuestSubmission(submission);
}
}
ReleaseGuestCommandBuffer(submission.CommandBuffer);
ReleaseGuestFence(submission.Fence, needsReset: true);
if (submission.Timeline > _completedTimeline)
private void RetireGuestSubmission(PendingGuestSubmission submission)
{
if (!_deviceLost)
{
foreach (var image in submission.TraceImages)
{
_completedTimeline = submission.Timeline;
TraceGuestImageContents(image);
}
}
ProcessDeferredTextureDestroys();
// The fence has signalled, so the detile dispatch that used these
// is done reading them; hand them back for the next texture.
foreach (var transients in submission.RetireDetile)
{
_detilePass?.Retire(transients);
}
foreach (var resources in submission.Resources)
{
DestroyTranslatedDrawResources(resources);
}
foreach (var (buffer, memory) in submission.RetireBuffers)
{
_vk.DestroyBuffer(_device, buffer, null);
_vk.FreeMemory(_device, memory, null);
}
ReleaseGuestCommandBuffer(submission.CommandBuffer);
ReleaseGuestFence(submission.Fence, needsReset: true);
if (submission.Timeline > _completedTimeline)
{
_completedTimeline = submission.Timeline;
}
}
private void WaitForAllGuestSubmissionsForCpuVisibility()
@@ -7675,7 +7895,7 @@ internal static unsafe class VulkanVideoPresenter
private void PumpHostMovieFrame()
{
if (!Bink2MovieBridge.TryDecodeNextFrame(
if (!HostMovieBridge.TryDecodeNextFrame(
advanceClock: _hostMovieLumaTextureAddress != 0 &&
_hostMovieChromaTextureAddress != 0,
out var pixels,
@@ -8135,8 +8355,7 @@ internal static unsafe class VulkanVideoPresenter
out TextureResource resource)
{
resource = default!;
if (!guestImage.IsCpuBacked ||
guestImage.Width != texture.Width ||
if (guestImage.Width != texture.Width ||
guestImage.Height != texture.Height ||
guestImage.Depth != GetGuestTextureDepth(texture.Type, texture.Depth) ||
IsGuestTexture3D(guestImage.Type) != IsGuestTexture3D(texture.Type) ||
@@ -8146,6 +8365,32 @@ internal static unsafe class VulkanVideoPresenter
return false;
}
// IsCpuBacked alone used to gate this path, but it is a latch that
// flips false the first time the address is used as a render target
// and never flips back. On PS5 that address is unified memory: a
// surface that was rendered into once and is later rewritten by the
// guest CPU (glyph atlas rasterization, a 4K UI sheet redrawn on the
// brightness screen) must still be re-read. Fall back on the write
// tracker, which reports genuine CPU stores and leaves pure
// render-into-then-sample feedback untouched.
bool hasUploadedGeneration;
long uploadedGeneration;
lock (_gate)
{
hasUploadedGeneration = _cpuBackedUploadGenerations.TryGetValue(
texture.Address,
out uploadedGeneration);
}
if (!ShouldRefreshGuestImageFromCpu(
guestImage.IsCpuBacked,
texture.WriteGeneration,
hasUploadedGeneration,
uploadedGeneration))
{
return false;
}
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, texture.Width)
: texture.Width;
@@ -9219,6 +9464,27 @@ internal static unsafe class VulkanVideoPresenter
!_guestImages.ContainsKey(texture.Address))
{
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
var canonicalView = view;
if (texture.DstSelect != 0xFAC)
{
var identityViewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = GetGuestTextureViewType(
texture.Type,
texture.ArrayedView),
Format = vkFormat,
Components = ToVkComponentMapping(0xFAC),
SubresourceRange = ColorSubresourceRange(layerCount: layers),
};
Check(
_vk.CreateImageView(_device, &identityViewInfo, null, out canonicalView),
"vkCreateImageView(texture identity)");
SetDebugName(ObjectType.ImageView, canonicalView.Handle, $"{debugName} identity view");
}
var guestImage = new GuestImageResource
{
Address = texture.Address,
@@ -9234,7 +9500,7 @@ internal static unsafe class VulkanVideoPresenter
Format = vkFormat,
Image = image,
Memory = imageMemory,
View = view,
View = canonicalView,
InitialUploadPending = true,
IsCpuBacked = true,
CpuContentFingerprint = contentFingerprint,
@@ -11134,6 +11400,8 @@ internal static unsafe class VulkanVideoPresenter
EnsureGuestSubmissionCapacity();
resources = CreateComputeDispatchResources(work);
FlushBatchedGuestCommands();
var batchCount = Math.Max(
1u,
(uint)Math.Ceiling(work.GroupCountZ / (double)MaxComputeZSlicesPerSubmission));
@@ -12075,6 +12343,7 @@ internal static unsafe class VulkanVideoPresenter
{
Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
$"vs=0x{work.ShaderAddress:X16} " +
$"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " +
"sampled aliases use ordered snapshots");
ReturnPooledGuestData(work.Draw);
@@ -12090,6 +12359,14 @@ internal static unsafe class VulkanVideoPresenter
? GetDepthOnlyColorTarget(depthOnlyTarget)
: work.Targets[index];
targets[index] = GetOrCreateGuestImage(targetDescriptor, formats[index]);
// A view-compatible alias accept can return an image whose
// identity differs from the request (sRGB vs UNORM
// counterpart). The render pass, framebuffer views, and
// pipeline cache key must all follow the format that actually
// backs the attachment; a pipeline keyed on the requested
// format could later be replayed inside a render pass of the
// other identity.
formats[index] = targets[index].Format;
if (work.Targets[index].Address != 0 &&
TakeGuestImageInitialData(work.Targets[index].Address) is { } initialData &&
!targets[index].Initialized &&
@@ -13089,6 +13366,20 @@ internal static unsafe class VulkanVideoPresenter
$"address 0x{target.Address:X16}.");
}
if (!supportsStorageUsage)
{
// sRGB targets must stay shareable with later UNORM
// ImageLoad/Store aliases of the same surface. The image is
// created with MUTABLE_FORMAT | EXTENDED_USAGE, so carrying
// storage usage is legal as long as the view-compatible UNORM
// counterpart supports storage; stores then go through the
// UNORM alias view instead of recreating the image.
var storageCounterpart = GetStorageImageFormat(format);
supportsStorageUsage = storageCounterpart != format &&
IsCompatibleViewFormat(format, storageCounterpart) &&
SupportsStorageImage(storageCounterpart);
}
// Storage/UAV images keep native guest dimensions (compute shaders index them directly).
var physicalWidth = requiresStorage
? target.Width
@@ -13114,13 +13405,29 @@ internal static unsafe class VulkanVideoPresenter
format);
if (_guestImages.TryGetValue(target.Address, out var existing))
{
// View-compatible formats (sRGB vs UNORM of the same texel
// layout) are the same guest surface accessed through
// different number formats — render as sRGB, ImageLoad/Store
// as UNORM is a standard PS5 pattern (AvPlayer movie copies,
// post-process chains). Recreating the image for that case
// ping-pongs content between two VkImages and every transition
// loses the rendered pixels; the mutable-format image accepts
// an alternate-format view instead. Aliasing is limited to
// sRGB/UNORM counterparts: broader same-class reinterpretation
// (e.g. R32Uint over R8G8B8A8Unorm) would attach pipelines
// whose fragment output type no longer matches the attachment,
// so those keep the recreate path.
var exactFormatMatch =
existing.GuestFormat == guestFormat &&
existing.Format == format;
if (existing.LogicalWidth == target.Width &&
existing.LogicalHeight == target.Height &&
existing.LogicalDepth == depth &&
existing.Type == type &&
existing.MipLevels == mipLevels &&
existing.GuestFormat == guestFormat &&
existing.Format == format)
(exactFormatMatch ||
(IsAliasableGuestImageFormat(existing.Format, format) &&
(!requiresStorage || existing.SupportsStorageUsage))))
{
if (requiresStorage && !existing.SupportsStorageUsage)
{
@@ -13204,20 +13511,33 @@ internal static unsafe class VulkanVideoPresenter
retained.IsCpuBacked = false;
retained.CpuContentFingerprint = 0;
_guestImages.Add(target.Address, retained);
var retainedByteCount = GetTextureByteCount(
target.Format,
target.Width,
target.Height,
depth);
lock (_gate)
{
_cpuBackedUploadGenerations.Remove(target.Address);
_guestImageExtents[target.Address] = (
target.Width,
target.Height,
GetTextureByteCount(
target.Format,
target.Width,
target.Height,
depth));
retainedByteCount);
}
TrackCpuBackedGuestImage(retained);
// Arm the exact extent the flip/acquire sync path would read
// back, budgeted by bytes rather than by resolution: the old
// 1920x1080 cap left every 4K surface permanently
// un-invalidated, so a guest CPU rewrite of one was never
// reflected and the sample served stale bytes.
if (ShouldTrackGuestImageWrites(retainedByteCount))
{
SharpEmu.HLE.GuestImageWriteTracker.Track(
target.Address,
retainedByteCount,
CurrentGuestWorkSequenceForDiagnostics,
"vulkan.render-target");
}
if (_traceGuestImageEvents)
{
@@ -13356,19 +13676,31 @@ internal static unsafe class VulkanVideoPresenter
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{debugName} framebuffer");
}
_guestImages.Add(target.Address, resource);
var createdByteCount = GetTextureByteCount(
target.Format,
target.Width,
target.Height,
depth);
lock (_gate)
{
_guestImageExtents[target.Address] = (
target.Width,
target.Height,
GetTextureByteCount(
target.Format,
target.Width,
target.Height,
depth));
createdByteCount);
}
TrackCpuBackedGuestImage(resource);
// See the retained-variant path above: track the full backing
// extent under a byte budget instead of a resolution cap so
// oversized render targets the guest later rewrites with the CPU
// are re-uploaded on the next sample.
if (ShouldTrackGuestImageWrites(createdByteCount))
{
SharpEmu.HLE.GuestImageWriteTracker.Track(
target.Address,
createdByteCount,
CurrentGuestWorkSequenceForDiagnostics,
"vulkan.render-target");
}
if (_traceGuestImageEvents)
{
@@ -13382,24 +13714,25 @@ internal static unsafe class VulkanVideoPresenter
private void TrackCpuBackedGuestImage(GuestImageResource image)
{
// Arm ≤1080p guest images so native CPU stores fault. Drain skips
// false overlap dirties with a 4 KiB zero probe unless IsCpuBacked.
if (image.Width == 0 ||
image.Height == 0 ||
image.Width > 1920 ||
image.Height > 1080)
if (image.Width == 0 || image.Height == 0)
{
return;
}
var depth = Math.Max(image.Depth, 1u);
var byteCount = GetVulkanImageByteCount(
image.Format,
image.Width,
image.Height,
depth);
if (!ShouldTrackGuestImageWrites(byteCount))
{
return;
}
SharpEmu.HLE.GuestImageWriteTracker.Track(
image.Address,
GetVulkanImageByteCount(
image.Format,
image.Width,
image.Height,
depth),
byteCount,
CurrentGuestWorkSequenceForDiagnostics,
"vulkan.render-target");
}
@@ -14109,8 +14442,23 @@ internal static unsafe class VulkanVideoPresenter
{
Format.R8Unorm or
Format.R8Uint or
Format.R8Sint => 8,
Format.R16Sfloat => 16,
Format.R8Sint or
Format.R8SNorm => 8,
// Every single-channel 16-bit format shares this class, not just
// the float one. Omitting the rest made GetVulkanImageByteCount
// return zero for them, and a zero expected size rejects the
// guest's upload outright — the texture then samples as blank
// for the life of the run. Silent Hill uploads R16Unorm at
// 144x81 through 1024x1024 and every one was dropped.
Format.R16Sfloat or
Format.R16Unorm or
Format.R16SNorm or
Format.R16Uint or
Format.R16Sint or
Format.R8G8Unorm or
Format.R8G8SNorm or
Format.R8G8Uint or
Format.R8G8Sint => 16,
Format.R32Uint or
Format.R32Sint or
Format.R32Sfloat or
@@ -14285,11 +14633,6 @@ internal static unsafe class VulkanVideoPresenter
pendingGuestWork.Queue.Name,
StringComparison.Ordinal))
{
// A host command buffer must never contain commands from
// two independent guest queues: an ordered action fences
// only its own queue's predecessor submissions.
// Keep the previous work label so a device-lost on this
// flush still names the draws that filled the batch.
FlushBatchedGuestCommands();
}
@@ -14329,14 +14672,7 @@ internal static unsafe class VulkanVideoPresenter
}
try
{
// A host-decoded movie only overrides which image gets
// presented (see the presentation selection below); the
// guest's own command stream keeps draining normally.
// Silently discarding these instead of executing them
// leaves guest-visible completion state (labels, buffers,
// job results) permanently unwritten, which desyncs the
// engine's own job system and previously crashed it
// shortly after the movie finished.
switch (work)
{
case VulkanOffscreenGuestDraw offscreenDraw:
@@ -14658,6 +14994,8 @@ internal static unsafe class VulkanVideoPresenter
$"[LOADER][ERROR] Vulkan VideoOut translated draw setup failed: {exception.Message}");
return;
}
FlushBatchedGuestCommands();
}
uint imageIndex;
@@ -15195,6 +15533,9 @@ internal static unsafe class VulkanVideoPresenter
Format.R8G8B8A8Uint or
Format.R8G8B8A8Sint or
Format.R8G8B8A8Unorm or
Format.R8G8B8A8Srgb or
Format.B8G8R8A8Unorm or
Format.B8G8R8A8Srgb or
Format.A2R10G10B10UnormPack32 or
Format.A2B10G10R10UnormPack32 => 4,
Format.R16G16B16A16Uint or
@@ -15269,6 +15610,11 @@ internal static unsafe class VulkanVideoPresenter
RecordGuestDepthForSampling(depth, shaderStage);
}
if (!texture.IsStorage && texture.GuestImage is { } sampledGuestImage)
{
RecordGuestImageForSampling(sampledGuestImage, shaderStage);
}
if (!texture.NeedsUpload)
{
continue;
@@ -15476,6 +15822,76 @@ internal static unsafe class VulkanVideoPresenter
depth.Layout = ImageLayout.ShaderReadOnlyOptimal;
}
private void RecordGuestImageForSampling(
GuestImageResource guestImage,
PipelineStageFlags shaderStage)
{
if (guestImage.Initialized || guestImage.InitialUploadPending)
{
return;
}
var range = ColorSubresourceRange(0, Math.Max(guestImage.MipLevels, 1));
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = guestImage.Image,
SubresourceRange = range,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransfer);
var clearValue = new ClearColorValue(0f, 0f, 0f, 0f);
_vk.CmdClearColorImage(
_commandBuffer,
guestImage.Image,
ImageLayout.TransferDstOptimal,
&clearValue,
1,
&range);
var toShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = guestImage.Image,
SubresourceRange = range,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&toShaderRead);
guestImage.Initialized = true;
}
private void RecordStandaloneGuestDepthClear(GuestDepthResource depth)
{
var depthRange = new ImageSubresourceRange(
@@ -392,6 +392,10 @@ public static partial class Gen5MslTranslator
if (input.ComponentCount is >= 1 and <= 4)
{
_vertexInputsByPc.TryAdd(input.Pc, input);
foreach (var aliasPc in input.AliasPcs ?? [])
{
_vertexInputsByPc.TryAdd(aliasPc, input);
}
}
}
}
@@ -903,6 +903,22 @@ public static partial class Gen5SpirvTranslator
width);
break;
}
case "VBfeI32":
{
// Same extract as VBfeU32 but sign-extended from the top bit
// of the extracted field, so the result type must be signed
// and bitcast back for storage.
var width = BitwiseAnd(GetRawSource(instruction, 2), UInt(31));
result = Bitcast(
_uintType,
_module.AddInstruction(
SpirvOp.BitFieldSExtract,
_intType,
Bitcast(_intType, GetRawSource(instruction, 0)),
BitwiseAnd(GetRawSource(instruction, 1), UInt(31)),
width));
break;
}
case "VBfiB32":
{
var mask = GetRawSource(instruction, 0);
@@ -2706,20 +2722,31 @@ public static partial class Gen5SpirvTranslator
if (applySdwaIntegerModifiers)
{
// SDWA ABS/NEG are floating-point sign-bit modifiers even on
// a bit-move opcode: ABS clears the sign bit, NEG flips it.
// Two's-complement negating the raw bits instead turns 1.0
// into -4.0 and -3.0 into 1.5, which silently skews every
// pass that y-flips its clip position with an SDWA-negated
// V_MOV_B32 - the whole of UE's DrawRectangle.
var signBit = selector switch
{
<= 3 => 0x80u,
4 or 5 => 0x8000u,
_ => 0x80000000u,
};
if ((sdwa.AbsoluteMask & (1u << sourceIndex)) != 0)
{
value = Bitcast(
_uintType,
Ext(5, _intType, Bitcast(_intType, value)));
value = BitwiseAnd(value, UInt(~signBit));
}
if ((sdwa.NegateMask & (1u << sourceIndex)) != 0)
{
value = _module.AddInstruction(
SpirvOp.ISub,
SpirvOp.BitwiseXor,
_uintType,
UInt(0),
value);
value,
UInt(signBit));
}
}
}
@@ -1365,14 +1365,18 @@ public static partial class Gen5SpirvTranslator
variable,
SpirvDecoration.Location,
input.Location);
_vertexInputsByPc.TryAdd(
input.Pc,
new SpirvVertexInput(
variable,
type,
componentType,
input.ComponentCount,
componentKind));
var vertexInput = new SpirvVertexInput(
variable,
type,
componentType,
input.ComponentCount,
componentKind);
_vertexInputsByPc.TryAdd(input.Pc, vertexInput);
foreach (var aliasPc in input.AliasPcs ?? [])
{
_vertexInputsByPc.TryAdd(aliasPc, vertexInput);
}
_interfaces.Add(variable);
}
}
+8 -1
View File
@@ -302,6 +302,12 @@ public sealed record Gen5GlobalMemoryBinding(
public bool WriteBackToGuest { get; set; } = true;
}
// One attribute per distinct guest stream view. AliasPcs carries the other
// fetch instructions that read the same view: uber-shaders fetch a stream from
// every material branch, and the scalar evaluator visits one instruction on
// several CFG paths. Both must resolve to this binding's single location,
// because Metal caps a vertex function at 31 attributes and one location per
// fetch instruction overruns that on UE's larger vertex shaders.
public sealed record Gen5VertexInputBinding(
uint Pc,
uint Location,
@@ -314,7 +320,8 @@ public sealed record Gen5VertexInputBinding(
byte[] Data,
int DataLength,
bool DataPooled,
bool PerInstance = false);
bool PerInstance = false,
IReadOnlyList<uint>? AliasPcs = null);
public sealed record Gen5ShaderEvaluation(
IReadOnlyList<uint> InitialScalarRegisters,
@@ -7,6 +7,7 @@ using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace SharpEmu.ShaderCompiler;
@@ -36,6 +37,113 @@ public static class Gen5ShaderScalarEvaluator
StringComparison.Ordinal);
private static readonly object _scalarFallbackTraceGate = new();
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = [];
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedDivergentDescriptors = [];
private static readonly ConditionalWeakTable<Gen5ShaderProgram, Ir.Gen5ScalarSsa> _scalarSsaCache = [];
private static readonly bool _divergentDescriptorGuard = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_IR_DESCRIPTOR_GUARD"),
"0",
StringComparison.Ordinal);
private static Ir.Gen5ScalarSsa GetScalarSsa(Gen5ShaderState state) =>
_scalarSsaCache.GetValue(
state.Program,
program => Ir.Gen5ScalarSsa.Build(program.Instructions, state.UserData));
/// <summary>
/// The byte offset comes from an SGPR. When the instruction that produced that
/// register is one the scalar evaluator cannot reproduce — a vector compare
/// writing VCC, say, whose value depends on per-lane data — the register still
/// holds whatever the linear walk left in it. Adding that to an otherwise valid
/// base address is how descriptors turned into addresses far out of range.
/// </summary>
private static bool IsOffsetFromUnmodelledWriter(
Gen5ShaderState state,
Gen5ShaderInstruction instruction,
Gen5ScalarMemoryControl control)
{
if (!_divergentDescriptorGuard || control.DynamicOffsetRegister is not { } offsetRegister)
{
return false;
}
var ssa = GetScalarSsa(state);
var reaching = ssa.GetReachingDefinitionAt(instruction.Pc, offsetRegister);
if (reaching.State == Ir.IrReachingState.Multiple)
{
return true;
}
if (reaching.State != Ir.IrReachingState.Single ||
reaching.DefinitionPc == uint.MaxValue)
{
return false;
}
var writer = state.Program.Instructions
.FirstOrDefault(candidate => candidate.Pc == reaching.DefinitionPc);
return writer is not null && Ir.Gen5ScalarSsa.WritesVccImplicitly(writer);
}
/// <summary>
/// A descriptor assembled from registers that differ per incoming path is not a
/// descriptor, it is whichever path the linear walk happened to take last.
/// </summary>
private static bool IsDescriptorFromDivergentMerge(
Gen5ShaderState state,
uint pc,
uint scalarBase,
uint registerCount)
{
if (!_divergentDescriptorGuard)
{
return false;
}
var ssa = GetScalarSsa(state);
if (!ssa.Graph.HasControlFlow)
{
return false;
}
for (var offset = 0u; offset < registerCount; offset++)
{
var reaching = ssa.GetReachingDefinitionAt(pc, scalarBase + offset);
if (reaching.State == Ir.IrReachingState.Multiple)
{
return true;
}
if (ssa.GetScalarAt(pc, scalarBase + offset).State == Ir.IrScalarState.Merged)
{
return true;
}
}
return false;
}
private static void TraceDivergentDescriptor(
Gen5ShaderState state,
Gen5ShaderInstruction instruction,
uint scalarBase,
ulong baseAddress)
{
lock (_scalarFallbackTraceGate)
{
if (!_tracedDivergentDescriptors.Add((state.Program.Address, instruction.Pc)))
{
return;
}
}
Console.Error.WriteLine(
$"[LOADER][WARN] agc.descriptor_divergent " +
$"shader=0x{state.Program.Address:X16} pc=0x{instruction.Pc:X} " +
$"op={instruction.Opcode} base=s{scalarBase} " +
$"linear_base_addr=0x{baseAddress:X16} (unbound instead of dereferenced)");
}
// Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
// Host submit of those translations has lost the Vulkan device; Agc skips
// them before QueueSubmit.
@@ -173,6 +281,13 @@ public static class Gen5ShaderScalarEvaluator
var globalMemoryBindings = new List<Gen5GlobalMemoryBinding>();
var globalMemoryByAddress = new Dictionary<(uint ScalarAddress, ulong BaseAddress), Gen5GlobalMemoryBinding>();
var vertexInputBindings = new List<Gen5VertexInputBinding>();
// Absolute element address plus record layout identifies the guest
// stream view an attribute reads, so every fetch that resolves to it
// shares one location instead of claiming a new one.
var vertexInputByView =
new Dictionary<(ulong Address, uint Stride, uint DataFormat,
uint NumberFormat, uint ComponentCount), int>();
var vertexInputAliasPcs = new List<List<uint>>();
// Shared, cached, read-only: computed once per decoded program. The
// set already includes every instruction's destination registers, so
// the per-load additions the loop used to make are redundant.
@@ -545,6 +660,41 @@ public static class Gen5ShaderScalarEvaluator
return false;
}
var vertexInputView = (
SaturatingAdd(
vertexInputBinding.BaseAddress,
vertexInputBinding.OffsetBytes),
vertexInputBinding.Stride,
vertexInputBinding.DataFormat,
vertexInputBinding.NumberFormat,
vertexInputBinding.ComponentCount);
if (vertexInputByView.TryGetValue(
vertexInputView,
out var existingVertexInput))
{
var aliasPcs = vertexInputAliasPcs[existingVertexInput];
if (!aliasPcs.Contains(instruction.Pc))
{
aliasPcs.Add(instruction.Pc);
}
// Descriptors for one view agree on size, but a path
// that resolved a larger reachable range still has to
// win so the capture covers every fetch.
var aliasedBinding = vertexInputBindings[existingVertexInput];
if (aliasedBinding.DataLength < vertexInputBinding.DataLength)
{
vertexInputBindings[existingVertexInput] = aliasedBinding with
{
DataLength = vertexInputBinding.DataLength,
};
}
continue;
}
vertexInputByView[vertexInputView] = vertexInputBindings.Count;
vertexInputAliasPcs.Add([]);
vertexInputBindings.Add(vertexInputBinding);
continue;
}
@@ -695,6 +845,18 @@ public static class Gen5ShaderScalarEvaluator
if (vertexInputBindings.Count != 0)
{
for (var index = 0; index < vertexInputBindings.Count; index++)
{
if (vertexInputAliasPcs[index].Count != 0)
{
vertexInputBindings[index] = vertexInputBindings[index] with
{
AliasPcs = vertexInputAliasPcs[index],
};
}
}
TraceVertexInputShape(vertexInputBindings);
if (!TryCaptureVertexInputData(
ctx,
vertexInputBindings,
@@ -843,6 +1005,43 @@ public static class Gen5ShaderScalarEvaluator
return true;
}
private static readonly bool _traceVertexInputShape =
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_VERTEX_SHAPE"),
"1",
StringComparison.Ordinal);
private static readonly HashSet<string> _tracedVertexInputShapes = [];
private static void TraceVertexInputShape(
IReadOnlyList<Gen5VertexInputBinding> bindings)
{
if (!_traceVertexInputShape)
{
return;
}
var distinctPcs = bindings.Select(static binding => binding.Pc).Distinct().Count();
var identities = bindings
.Select(static binding =>
$"{binding.BaseAddress:X}/{binding.Stride}/{binding.OffsetBytes}/" +
$"{binding.DataFormat}/{binding.NumberFormat}/{binding.ComponentCount}")
.ToArray();
var shape =
$"count={bindings.Count} distinct_pc={distinctPcs} " +
$"distinct_view={identities.Distinct().Count()} " +
$"views={string.Join(',', identities)}";
lock (_tracedVertexInputShapes)
{
if (!_tracedVertexInputShapes.Add(shape))
{
return;
}
}
Console.Error.WriteLine($"[VERTEX-SHAPE] {shape}");
}
private static void TraceTitleVertexInputs(IReadOnlyList<Gen5VertexInputBinding> bindings)
{
if (!string.Equals(
@@ -1898,19 +2097,32 @@ public static class Gen5ShaderScalarEvaluator
var address = unchecked(
baseAddress +
byteOffset) & ~3UL;
var descriptorDiverged = IsDescriptorFromDivergentMerge(
state,
instruction.Pc,
scalarBase.Value,
isBufferLoad ? 4u : 2u) ||
IsOffsetFromUnmodelledWriter(state, instruction, control);
if (descriptorDiverged)
{
TraceDivergentDescriptor(state, instruction, scalarBase.Value, baseAddress);
}
var bufferUnbound =
isBufferLoad &&
(!hasBufferDescriptor ||
(descriptorDiverged ||
!hasBufferDescriptor ||
bufferDescriptor.SizeBytes == 0 ||
(scalarRegisters[scalarBase.Value] == 0 &&
scalarRegisters[scalarBase.Value + 1] == 0 &&
scalarBase.Value + 3 < ScalarRegisterCount &&
scalarRegisters[scalarBase.Value + 2] == 0 &&
scalarRegisters[scalarBase.Value + 3] == 0));
var scalarPointerUnbound = ShouldTreatScalarPointerAsUnbound(
isBufferLoad,
address,
_strictScalarLoad);
var scalarPointerUnbound = descriptorDiverged && !isBufferLoad ||
ShouldTreatScalarPointerAsUnbound(
isBufferLoad,
address,
_strictScalarLoad);
if (scalarPointerUnbound)
{
TraceScalarPointerFallback(
@@ -1157,6 +1157,7 @@ public static class Gen5ShaderTranslator
0x15D => "VSadU32",
0x15E => "VCvtPkU8F32",
0x148 => "VBfeU32",
0x149 => "VBfeI32",
0x169 => "VMulLoU32",
0x16A => "VMulHiU32",
0x16B => "VMulLoI32",
@@ -1170,6 +1171,7 @@ public static class Gen5ShaderTranslator
0x366 => "VMbcntHiU32B32",
0x368 => "VCvtPknormI16F32",
0x369 => "VCvtPknormU16F32",
0x36A => "VCvtPkU16U32",
0x373 => "VMadU32U16",
0x346 => "VLshlAddU32",
0x347 => "VAddLshlU32",
@@ -0,0 +1,67 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
namespace SharpEmu.ShaderCompiler.Ir;
public sealed class Gen5IrBranchResolver : IIrBranchResolver
{
public static Gen5IrBranchResolver Instance { get; } = new();
public bool IsBranch(Gen5ShaderInstruction instruction) =>
IsUnconditionalBranch(instruction) ||
IsConditional(instruction) ||
IsTerminator(instruction);
public bool IsConditional(Gen5ShaderInstruction instruction) => instruction.Opcode switch
{
"SCbranchScc0" or
"SCbranchScc1" or
"SCbranchVccz" or
"SCbranchVccnz" or
"SCbranchExecz" or
"SCbranchExecnz" or
"SCbranchCdbgsys" or
"SCbranchCdbguser" or
"SCbranchCdbgsysOrUser" or
"SCbranchCdbgsysAndUser" => true,
_ => false,
};
public bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc)
{
targetPc = 0;
if (IsTerminator(instruction))
{
return false;
}
if (!IsUnconditionalBranch(instruction) && !IsConditional(instruction))
{
return false;
}
if (instruction.Encoding != Gen5ShaderEncoding.Sopp || instruction.Words.Count == 0)
{
return false;
}
var offset = unchecked((short)(instruction.Words[0] & 0xFFFF));
var nextPc = (long)instruction.Pc + instruction.Words.Count * sizeof(uint);
var target = nextPc + offset * sizeof(uint);
if (target < 0 || target > uint.MaxValue)
{
return false;
}
targetPc = (uint)target;
return true;
}
public static bool IsUnconditionalBranch(Gen5ShaderInstruction instruction) =>
string.Equals(instruction.Opcode, "SBranch", StringComparison.Ordinal);
public static bool IsTerminator(Gen5ShaderInstruction instruction) =>
instruction.Opcode is "SEndpgm" or "SEndpgmSaved";
}
@@ -0,0 +1,498 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using System.Linq;
namespace SharpEmu.ShaderCompiler.Ir;
public enum IrScalarState
{
Unknown,
Constant,
Merged,
}
public enum IrReachingState
{
None,
Single,
Multiple,
}
public readonly record struct IrReachingDefinition(IrReachingState State, uint DefinitionPc)
{
public static readonly IrReachingDefinition None = new(IrReachingState.None, 0);
public static readonly IrReachingDefinition Multiple = new(IrReachingState.Multiple, 0);
public static IrReachingDefinition At(uint pc) => new(IrReachingState.Single, pc);
public IrReachingDefinition Join(IrReachingDefinition other)
{
if (State == IrReachingState.None)
{
return other;
}
if (other.State == IrReachingState.None)
{
return this;
}
if (State == IrReachingState.Single &&
other.State == IrReachingState.Single &&
DefinitionPc == other.DefinitionPc)
{
return this;
}
return Multiple;
}
}
public readonly record struct IrScalarValue(IrScalarState State, uint Constant)
{
public static readonly IrScalarValue Unknown = new(IrScalarState.Unknown, 0);
public static readonly IrScalarValue Merged = new(IrScalarState.Merged, 0);
public static IrScalarValue FromConstant(uint value) => new(IrScalarState.Constant, value);
public bool IsResolved => State == IrScalarState.Constant;
public IrScalarValue Join(IrScalarValue other)
{
if (State == IrScalarState.Unknown)
{
return other;
}
if (other.State == IrScalarState.Unknown)
{
return this;
}
if (State == IrScalarState.Constant &&
other.State == IrScalarState.Constant &&
Constant == other.Constant)
{
return this;
}
return Merged;
}
}
public sealed class Gen5ScalarSsa
{
public const int ScalarRegisterCount = 256;
private Gen5ScalarSsa(
IrControlFlowGraph graph,
IReadOnlyList<IrScalarValue[]> entryState,
IReadOnlyList<IrScalarValue[]> exitState,
IReadOnlyList<IrReachingDefinition[]> entryDefinitions,
IReadOnlyDictionary<uint, int> blockByPc,
IReadOnlyList<Gen5ShaderInstruction> instructions)
{
Graph = graph;
_entryState = entryState;
_exitState = exitState;
_entryDefinitions = entryDefinitions;
_blockByPc = blockByPc;
_instructions = instructions;
}
private readonly IReadOnlyList<IrReachingDefinition[]> _entryDefinitions;
public IrControlFlowGraph Graph { get; }
private readonly IReadOnlyList<IrScalarValue[]> _entryState;
private readonly IReadOnlyList<IrScalarValue[]> _exitState;
private readonly IReadOnlyDictionary<uint, int> _blockByPc;
private readonly IReadOnlyList<Gen5ShaderInstruction> _instructions;
public static Gen5ScalarSsa Build(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IReadOnlyList<uint> userData,
IIrBranchResolver? resolver = null)
{
resolver ??= Gen5IrBranchResolver.Instance;
var graph = IrControlFlowGraph.Build(instructions, resolver);
var blockCount = graph.Blocks.Count;
var entry = new List<IrScalarValue[]>(blockCount);
var exit = new List<IrScalarValue[]>(blockCount);
var defEntry = new List<IrReachingDefinition[]>(blockCount);
var defExit = new List<IrReachingDefinition[]>(blockCount);
for (var index = 0; index < blockCount; index++)
{
entry.Add(NewState());
exit.Add(NewState());
defEntry.Add(NewDefinitions());
defExit.Add(NewDefinitions());
}
if (blockCount > 0)
{
var initial = entry[0];
for (var index = 0; index < userData.Count && index < ScalarRegisterCount; index++)
{
initial[index] = IrScalarValue.FromConstant(userData[index]);
}
}
var blockByPc = new Dictionary<uint, int>();
for (var blockIndex = 0; blockIndex < blockCount; blockIndex++)
{
var range = graph.Blocks[blockIndex];
foreach (var instruction in instructions)
{
if (instruction.Pc >= range.StartPc && instruction.Pc < range.EndPc)
{
blockByPc[instruction.Pc] = blockIndex;
}
}
}
var worklist = new Queue<int>();
for (var index = 0; index < blockCount; index++)
{
worklist.Enqueue(index);
}
var visits = new int[blockCount];
const int visitLimit = 8;
while (worklist.Count > 0)
{
var blockIndex = worklist.Dequeue();
if (visits[blockIndex]++ > visitLimit)
{
continue;
}
var state = (IrScalarValue[])entry[blockIndex].Clone();
if (graph.Predecessors[blockIndex].Count > 0)
{
state = NewState();
var first = true;
foreach (var predecessor in graph.Predecessors[blockIndex])
{
var incoming = exit[predecessor];
for (var register = 0; register < ScalarRegisterCount; register++)
{
state[register] = first
? incoming[register]
: state[register].Join(incoming[register]);
}
first = false;
}
if (blockIndex == 0)
{
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
{
state[register] = state[register].Join(
IrScalarValue.FromConstant(userData[register]));
}
}
}
entry[blockIndex] = state;
var definitions = NewDefinitions();
if (graph.Predecessors[blockIndex].Count == 0)
{
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
{
definitions[register] = IrReachingDefinition.At(uint.MaxValue);
}
}
else
{
var first = true;
foreach (var predecessor in graph.Predecessors[blockIndex])
{
var incoming = defExit[predecessor];
for (var register = 0; register < ScalarRegisterCount; register++)
{
definitions[register] = first
? incoming[register]
: definitions[register].Join(incoming[register]);
}
first = false;
}
}
defEntry[blockIndex] = definitions;
var computed = Transfer(instructions, graph.Blocks[blockIndex], state);
var computedDefinitions = TransferDefinitions(
instructions,
graph.Blocks[blockIndex],
definitions);
var changed = !SameState(exit[blockIndex], computed) ||
!SameDefinitions(defExit[blockIndex], computedDefinitions);
exit[blockIndex] = computed;
defExit[blockIndex] = computedDefinitions;
if (changed)
{
foreach (var successor in graph.Successors[blockIndex])
{
worklist.Enqueue(successor);
}
}
}
return new Gen5ScalarSsa(graph, entry, exit, defEntry, blockByPc, instructions);
}
public IrScalarValue GetScalarAt(uint pc, uint register)
{
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
{
return IrScalarValue.Unknown;
}
var state = (IrScalarValue[])_entryState[blockIndex].Clone();
var range = _graphRange(blockIndex);
foreach (var instruction in _instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
if (instruction.Pc >= pc)
{
break;
}
Apply(instruction, state);
}
return state[register];
}
public IrReachingDefinition GetReachingDefinitionAt(uint pc, uint register)
{
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
{
return IrReachingDefinition.None;
}
var definitions = (IrReachingDefinition[])_entryDefinitions[blockIndex].Clone();
var range = _graphRange(blockIndex);
foreach (var instruction in _instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
if (instruction.Pc >= pc)
{
break;
}
ApplyDefinitions(instruction, definitions);
}
return definitions[register];
}
public bool IsInsideDivergentMerge(uint pc) =>
_blockByPc.TryGetValue(pc, out var blockIndex) &&
_graphPredecessorCount(blockIndex) > 1;
private IrBlockRange _graphRange(int blockIndex) => Graph.Blocks[blockIndex];
private int _graphPredecessorCount(int blockIndex) => Graph.Predecessors[blockIndex].Count;
private static IrScalarValue[] NewState()
{
var state = new IrScalarValue[ScalarRegisterCount];
for (var index = 0; index < state.Length; index++)
{
state[index] = IrScalarValue.Unknown;
}
return state;
}
private static IrReachingDefinition[] NewDefinitions()
{
var definitions = new IrReachingDefinition[ScalarRegisterCount];
for (var index = 0; index < definitions.Length; index++)
{
definitions[index] = IrReachingDefinition.None;
}
return definitions;
}
private static bool SameDefinitions(IrReachingDefinition[] left, IrReachingDefinition[] right)
{
for (var index = 0; index < left.Length; index++)
{
if (!left[index].Equals(right[index]))
{
return false;
}
}
return true;
}
private static IrReachingDefinition[] TransferDefinitions(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IrBlockRange range,
IrReachingDefinition[] entry)
{
var definitions = (IrReachingDefinition[])entry.Clone();
foreach (var instruction in instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
ApplyDefinitions(instruction, definitions);
}
return definitions;
}
public const uint VccLo = 106;
public const uint VccHi = 107;
/// <summary>
/// VOPC compares and the VOP2 carry forms write VCC without naming it: the ISA
/// makes the destination implicit in the encoding, so the decoded instruction
/// carries no destination operand for it. Modelling that here (rather than in
/// the shared decoder) keeps the linear evaluator's behaviour untouched while
/// letting the dataflow see that VCC was written.
/// </summary>
public static bool WritesVccImplicitly(Gen5ShaderInstruction instruction)
{
if (instruction.Encoding == Gen5ShaderEncoding.Vopc)
{
return true;
}
return instruction.Encoding == Gen5ShaderEncoding.Vop2 &&
instruction.Opcode is
"VAddCoCiU32" or
"VSubCoCiU32" or
"VSubrevCoCiU32";
}
private static void ApplyDefinitions(
Gen5ShaderInstruction instruction,
IrReachingDefinition[] definitions)
{
foreach (var destination in instruction.Destinations)
{
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
destination.Value >= ScalarRegisterCount)
{
continue;
}
definitions[destination.Value] = IrReachingDefinition.At(instruction.Pc);
}
if (WritesVccImplicitly(instruction))
{
definitions[VccLo] = IrReachingDefinition.At(instruction.Pc);
definitions[VccHi] = IrReachingDefinition.At(instruction.Pc);
}
}
private static bool SameState(IrScalarValue[] left, IrScalarValue[] right)
{
for (var index = 0; index < left.Length; index++)
{
if (!left[index].Equals(right[index]))
{
return false;
}
}
return true;
}
private static IrScalarValue[] Transfer(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IrBlockRange range,
IrScalarValue[] entry)
{
var state = (IrScalarValue[])entry.Clone();
foreach (var instruction in instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
Apply(instruction, state);
}
return state;
}
private static void Apply(Gen5ShaderInstruction instruction, IrScalarValue[] state)
{
var resolved = ResolveResult(instruction, state);
foreach (var destination in instruction.Destinations)
{
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
destination.Value >= ScalarRegisterCount)
{
continue;
}
state[destination.Value] = resolved;
}
}
private static IrScalarValue ResolveResult(
Gen5ShaderInstruction instruction,
IrScalarValue[] state)
{
if (instruction.Destinations.Count != 1)
{
return IrScalarValue.Unknown;
}
return instruction.Opcode switch
{
"SMov" or "SMovB32" => Source(instruction, state, 0),
_ => IrScalarValue.Unknown,
};
}
private static IrScalarValue Source(
Gen5ShaderInstruction instruction,
IrScalarValue[] state,
int index)
{
if (index >= instruction.Sources.Count)
{
return IrScalarValue.Unknown;
}
var source = instruction.Sources[index];
return source.Kind switch
{
Gen5OperandKind.ScalarRegister when source.Value < ScalarRegisterCount =>
state[source.Value],
Gen5OperandKind.LiteralConstant => IrScalarValue.FromConstant(source.Value),
_ => IrScalarValue.Unknown,
};
}
}
@@ -0,0 +1,161 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using System.Linq;
namespace SharpEmu.ShaderCompiler.Ir;
public readonly record struct IrBlockRange(uint StartPc, uint EndPc);
public sealed class IrControlFlowGraph
{
private IrControlFlowGraph(
IReadOnlyList<IrBlockRange> blocks,
IReadOnlyDictionary<uint, int> blockByStartPc,
IReadOnlyList<IReadOnlyList<int>> successors,
IReadOnlyList<IReadOnlyList<int>> predecessors,
IReadOnlySet<int> loopHeaders)
{
Blocks = blocks;
BlockByStartPc = blockByStartPc;
Successors = successors;
Predecessors = predecessors;
LoopHeaders = loopHeaders;
}
public IReadOnlyList<IrBlockRange> Blocks { get; }
public IReadOnlyDictionary<uint, int> BlockByStartPc { get; }
public IReadOnlyList<IReadOnlyList<int>> Successors { get; }
public IReadOnlyList<IReadOnlyList<int>> Predecessors { get; }
public IReadOnlySet<int> LoopHeaders { get; }
public bool HasControlFlow => Blocks.Count > 1;
public static IrControlFlowGraph Build(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IIrBranchResolver resolver)
{
var leaders = new SortedSet<uint>();
if (instructions.Count > 0)
{
leaders.Add(instructions[0].Pc);
}
for (var index = 0; index < instructions.Count; index++)
{
var instruction = instructions[index];
if (!resolver.IsBranch(instruction))
{
continue;
}
if (resolver.TryGetBranchTarget(instruction, out var target))
{
leaders.Add(target);
}
if (index + 1 < instructions.Count)
{
leaders.Add(instructions[index + 1].Pc);
}
}
var ordered = leaders.ToList();
var ranges = new List<IrBlockRange>(ordered.Count);
var byStart = new Dictionary<uint, int>();
for (var index = 0; index < ordered.Count; index++)
{
var start = ordered[index];
var end = index + 1 < ordered.Count
? ordered[index + 1]
: instructions.Count > 0 ? instructions[^1].Pc + 1 : start;
byStart[start] = ranges.Count;
ranges.Add(new IrBlockRange(start, end));
}
var successors = new List<List<int>>(ranges.Count);
var predecessors = new List<List<int>>(ranges.Count);
for (var index = 0; index < ranges.Count; index++)
{
successors.Add([]);
predecessors.Add([]);
}
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
{
var range = ranges[blockIndex];
var last = instructions
.Where(candidate => candidate.Pc >= range.StartPc && candidate.Pc < range.EndPc)
.LastOrDefault();
if (last is null)
{
continue;
}
var isBranch = resolver.IsBranch(last);
var hasTarget = isBranch && resolver.TryGetBranchTarget(last, out var target) &&
byStart.TryGetValue(target, out var targetIndex);
if (hasTarget)
{
_ = resolver.TryGetBranchTarget(last, out var resolved);
Link(successors, predecessors, blockIndex, byStart[resolved]);
}
var fallsThrough = !isBranch || resolver.IsConditional(last);
if (fallsThrough && blockIndex + 1 < ranges.Count)
{
Link(successors, predecessors, blockIndex, blockIndex + 1);
}
}
var headers = new HashSet<int>();
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
{
foreach (var successor in successors[blockIndex])
{
if (successor <= blockIndex)
{
headers.Add(successor);
}
}
}
return new IrControlFlowGraph(
ranges,
byStart,
successors.Select(list => (IReadOnlyList<int>)list).ToList(),
predecessors.Select(list => (IReadOnlyList<int>)list).ToList(),
headers);
}
private static void Link(
List<List<int>> successors,
List<List<int>> predecessors,
int from,
int to)
{
if (!successors[from].Contains(to))
{
successors[from].Add(to);
}
if (!predecessors[to].Contains(from))
{
predecessors[to].Add(from);
}
}
}
public interface IIrBranchResolver
{
bool IsBranch(Gen5ShaderInstruction instruction);
bool IsConditional(Gen5ShaderInstruction instruction);
bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc);
}
@@ -0,0 +1,243 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
// The kernel event-queue registry is process-wide static state; serialize against
// other suites that register graphics events.
[CollectionDefinition(AgcCommandBufferChainCollection.Name, DisableParallelization = true)]
public sealed class AgcCommandBufferChainCollection
{
public const string Name = "AgcCommandBufferChainState";
}
// A submission is one link of a chain, not always the whole command stream. When a
// title's command arena fills mid-frame it continues in a fresh buffer, links the two
// with an INDIRECT_BUFFER packet and submits only the first link, so a parser that
// stops at the end of the submitted window silently drops the rest of that frame --
// including its flip and the end-of-frame labels the guest waits on.
[Collection(AgcCommandBufferChainCollection.Name)]
public sealed class AgcCommandBufferChainTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const int MemorySize = 0x4000;
private const ulong HandleOutAddress = BaseAddress + 0x100;
private const ulong EventsAddress = BaseAddress + 0x200;
private const ulong OutCountAddress = BaseAddress + 0x300;
private const ulong TimeoutAddress = BaseAddress + 0x400;
private const ulong SubmitPacketAddress = BaseAddress + 0x500;
private const ulong StackAddress = BaseAddress + 0x600;
private const ulong CommandBufferAddress = BaseAddress + 0x800;
private const ulong FirstLinkAddress = BaseAddress + 0x1000;
private const ulong SecondLinkAddress = BaseAddress + 0x2000;
private const ulong WaitLabelAddress = BaseAddress + 0x3000;
// Graphics-queue completions land on ident 0, so its absence is how a suspended
// queue is observed without standing up a GPU backend.
private const ulong GraphicsCompletionIdent = 0;
[Fact]
public void SubmittedDcb_FollowsIndirectBufferIntoTheChainedBuffer()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var equeue = CreateEqueue(ctx, memory);
try
{
RegisterGraphicsCompletion(equeue);
// The chained buffer parks on a label that never reaches its reference,
// so reaching it at all suspends the queue.
var secondLinkDwords = WriteUnsatisfiedWait(ctx, memory, SecondLinkAddress);
var firstLinkDwords = WriteChain(
ctx,
memory,
FirstLinkAddress,
SecondLinkAddress,
secondLinkDwords);
SubmitDcb(ctx, memory, FirstLinkAddress, firstLinkDwords);
Assert.NotEqual(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
WaitEqueue(ctx, memory, equeue));
}
finally
{
DeleteEqueue(ctx, equeue);
}
}
// Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they decided not to
// take. Treating that as a redirect truncates the frame it appears in.
[Fact]
public void SubmittedDcb_KeepsParsingPastAnEmptyIndirectBuffer()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var equeue = CreateEqueue(ctx, memory);
try
{
RegisterGraphicsCompletion(equeue);
var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0);
var waitDwords = WriteUnsatisfiedWait(
ctx,
memory,
FirstLinkAddress + (paddingDwords * sizeof(uint)));
SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords + waitDwords);
Assert.NotEqual(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
WaitEqueue(ctx, memory, equeue));
}
finally
{
DeleteEqueue(ctx, equeue);
}
}
// A chain whose target is never satisfied must not be mistaken for a completed
// submission: without the redirect the empty first link completes immediately.
[Fact]
public void SubmittedDcb_WithoutAChain_CompletesImmediately()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var equeue = CreateEqueue(ctx, memory);
try
{
RegisterGraphicsCompletion(equeue);
var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0);
SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
WaitEqueue(ctx, memory, equeue));
Assert.Equal(GraphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00));
}
finally
{
DeleteEqueue(ctx, equeue);
}
}
private static void RegisterGraphicsCompletion(ulong equeue) =>
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
equeue,
GraphicsCompletionIdent,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0));
private static uint WriteChain(
CpuContext ctx,
FakeCpuMemory memory,
ulong linkAddress,
ulong target,
uint targetDwords)
{
PointCommandBufferAt(memory, linkAddress);
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = target;
ctx[CpuRegister.Rdx] = targetDwords;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbJump(ctx));
Assert.Equal(linkAddress, ctx[CpuRegister.Rax]);
return 4;
}
private static uint WriteUnsatisfiedWait(CpuContext ctx, FakeCpuMemory memory, ulong linkAddress)
{
PointCommandBufferAt(memory, linkAddress);
WriteUInt32(memory, WaitLabelAddress, 0);
ctx[CpuRegister.Rsp] = StackAddress;
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 0; // 32-bit compare
ctx[CpuRegister.Rdx] = 3; // equal
ctx[CpuRegister.Rcx] = 4; // memory space
ctx[CpuRegister.R8] = 2;
ctx[CpuRegister.R9] = WaitLabelAddress;
WriteUInt64(memory, StackAddress + 8, 1); // reference the label never reaches
WriteUInt64(memory, StackAddress + 16, 0xFFFF_FFFF); // mask
WriteUInt32(memory, StackAddress + 24, 0x10); // poll interval
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
return 7;
}
private static void PointCommandBufferAt(FakeCpuMemory memory, ulong linkAddress)
{
WriteUInt64(memory, CommandBufferAddress + 0x10, linkAddress);
WriteUInt64(memory, CommandBufferAddress + 0x18, linkAddress + 0x400);
}
private static void SubmitDcb(
CpuContext ctx,
FakeCpuMemory memory,
ulong commandAddress,
uint dwordCount)
{
WriteUInt64(memory, SubmitPacketAddress, commandAddress);
WriteUInt32(memory, SubmitPacketAddress + 8, dwordCount);
ctx[CpuRegister.Rdi] = SubmitPacketAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverSubmitDcb(ctx));
}
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
{
ctx[CpuRegister.Rdi] = HandleOutAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
return ReadUInt64(memory, HandleOutAddress);
}
private static void DeleteEqueue(CpuContext ctx, ulong equeue)
{
ctx[CpuRegister.Rdi] = equeue;
_ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx);
}
private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue)
{
WriteUInt64(memory, TimeoutAddress, 0);
ctx[CpuRegister.Rdi] = equeue;
ctx[CpuRegister.Rsi] = EventsAddress;
ctx[CpuRegister.Rdx] = 4;
ctx[CpuRegister.Rcx] = OutCountAddress;
ctx[CpuRegister.R8] = TimeoutAddress;
return KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}
@@ -8,10 +8,20 @@ using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
// The kernel event-queue registry is process-wide static state, and these tests assert over
// every graphics registration in it, so they cannot run beside another suite that registers
// graphics events.
[CollectionDefinition(GraphicsEventQueueStateCollection.Name, DisableParallelization = true)]
public sealed class GraphicsEventQueueStateCollection
{
public const string Name = "GraphicsEventQueueState";
}
// IT_EVENT_WRITE carries a 6-bit hardware EVENT_TYPE, but sceAgcDriverAddEqEvent registers the
// listener with a guest-defined eventId. Those two values are not the same numbering scheme, so
// exact ident matching never wakes anything (issue #173). TriggerRegisteredEventsByFilter wakes
// every graphics registration instead.
[Collection(GraphicsEventQueueStateCollection.Name)]
public sealed class AgcEventQueueTests
{
private const ulong BaseAddress = 0x1_0000_0000;
@@ -66,10 +76,20 @@ public sealed class AgcEventQueueTests
// Verify the queued event carries the registered ident and the event type as data.
Assert.Equal(registeredEventId, ReadUInt64(memory, eventsAddress + 0x00));
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterGraphics, ReadInt16(memory, eventsAddress + 0x08));
Assert.Equal(0u, ReadUInt16(memory, eventsAddress + 0x0A));
Assert.Equal(
KernelEventQueueCompatExports.KernelEventFlagClear,
ReadUInt16(memory, eventsAddress + 0x0A));
Assert.Equal(1u, ReadUInt32(memory, eventsAddress + 0x0C));
Assert.Equal(eventType, ReadUInt64(memory, eventsAddress + 0x10));
Assert.Equal(userData, ReadUInt64(memory, eventsAddress + 0x18));
// Registrations live in process-wide static state, and a sibling test asserts that
// no graphics registration exists at all. Drop this one instead of relying on
// execution order.
ctx[CpuRegister.Rdi] = handle;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
}
[Fact]
@@ -99,6 +119,370 @@ public sealed class AgcEventQueueTests
Assert.Equal(0, triggered);
}
[Fact]
public void CapturedCompletion_DoesNotWakeDeleteAndReAddGeneration()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
const ulong eventId = 0x20;
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
handle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0x1111));
var staleSnapshot =
KernelEventQueueCompatExports.CaptureRegisteredEvents(
memory,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics);
Assert.Single(staleSnapshot.Targets);
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
handle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics));
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
handle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0x2222));
var staleDelivery =
KernelEventQueueCompatExports.TriggerCapturedEvents(
staleSnapshot,
eventId);
Assert.Equal(0, staleDelivery.TriggeredCount);
Assert.Equal(1, staleDelivery.StaleCount);
Assert.False(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
handle,
out _));
var liveSnapshot =
KernelEventQueueCompatExports.CaptureRegisteredEvents(
memory,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics);
var liveDelivery =
KernelEventQueueCompatExports.TriggerCapturedEvents(
liveSnapshot,
eventId);
Assert.Equal(1, liveDelivery.TriggeredCount);
Assert.Equal(0, liveDelivery.StaleCount);
Assert.True(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
handle,
out var delivered));
Assert.Equal(0x2222UL, delivered.UserData);
DeleteEqueue(ctx, handle);
}
[Fact]
public void CapturedCompletion_PreservesEachQueuesRegistrationGeneration()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var firstHandle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
var secondHandle = CreateEqueue(ctx, memory, BaseAddress + 0x108);
const ulong eventId = 0x20;
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
firstHandle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0xAAAA));
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
secondHandle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0xBBBB));
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
memory,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics);
Assert.Equal(2, snapshot.Targets.Length);
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
secondHandle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics));
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
snapshot,
eventId);
Assert.Equal(1, delivery.TriggeredCount);
Assert.Equal(1, delivery.StaleCount);
Assert.True(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
firstHandle,
out var delivered));
Assert.Equal(0xAAAAUL, delivered.UserData);
Assert.False(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
secondHandle,
out _));
DeleteEqueue(ctx, firstHandle);
DeleteEqueue(ctx, secondHandle);
}
[Fact]
public void CapturedCompletion_DoesNotWakeDeletedEqueue()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
handle,
ident: 0,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0));
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
memory,
ident: 0,
KernelEventQueueCompatExports.KernelEventFilterGraphics);
DeleteEqueue(ctx, handle);
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
snapshot,
data: 0);
Assert.Equal(0, delivery.TriggeredCount);
Assert.Equal(1, delivery.StaleCount);
}
[Fact]
public void CapturedCompletion_IsScopedToCreatingRuntime()
{
var firstMemory = new FakeCpuMemory(BaseAddress, MemorySize);
var secondMemory = new FakeCpuMemory(BaseAddress, MemorySize);
var firstContext = new CpuContext(firstMemory, Generation.Gen5);
var secondContext = new CpuContext(secondMemory, Generation.Gen5);
var firstHandle = CreateEqueue(
firstContext,
firstMemory,
BaseAddress + 0x100);
var secondHandle = CreateEqueue(
secondContext,
secondMemory,
BaseAddress + 0x100);
const ulong eventId = 0x20;
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
firstHandle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0x1111));
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
secondHandle,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0x2222));
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
firstMemory,
eventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics);
Assert.Single(snapshot.Targets);
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
snapshot,
eventId);
Assert.Equal(1, delivery.TriggeredCount);
Assert.Equal(0, delivery.StaleCount);
Assert.True(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
firstHandle,
out var delivered));
Assert.Equal(0x1111UL, delivered.UserData);
Assert.False(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
secondHandle,
out _));
DeleteEqueue(firstContext, firstHandle);
DeleteEqueue(secondContext, secondHandle);
}
[Fact]
public void OnePendingEventCanBeReservedByOnlyOneWaiter()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
const ulong handleOutAddress = BaseAddress + 0x100;
ctx[CpuRegister.Rdi] = handleOutAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
var handle = ReadUInt64(memory, handleOutAddress);
Assert.True(KernelEventQueueCompatExports.EnqueueEvent(
handle,
new KernelEventQueueCompatExports.KernelQueuedEvent(
7,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
KernelEventQueueCompatExports.KernelEventFlagClear,
1,
0,
0)));
Assert.Equal(
1,
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
handle,
eventCapacity: 1));
Assert.Equal(
0,
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
handle,
eventCapacity: 1));
}
[Fact]
public void ZeroTimeoutWithNoEventReturnsWithoutHostWait()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
const ulong handleOutAddress = BaseAddress + 0x100;
const ulong eventsAddress = BaseAddress + 0x200;
const ulong outCountAddress = BaseAddress + 0x300;
const ulong timeoutAddress = BaseAddress + 0x400;
ctx[CpuRegister.Rdi] = handleOutAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
var handle = ReadUInt64(memory, handleOutAddress);
WriteUInt64(memory, timeoutAddress, 0);
ctx[CpuRegister.Rdi] = handle;
ctx[CpuRegister.Rsi] = eventsAddress;
ctx[CpuRegister.Rdx] = 1;
ctx[CpuRegister.Rcx] = outCountAddress;
ctx[CpuRegister.R8] = timeoutAddress;
var result = KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
result);
Assert.Equal(0u, ReadUInt32(memory, outCountAddress));
Assert.True(
KernelEventQueueCompatExports.IsSynchronousPoll(
timeoutAddress,
timeoutUsec: 0));
}
[Fact]
public void LevelUserEventPersistsButEdgeEventClears()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
const ulong handleOutAddress = BaseAddress + 0x100;
ctx[CpuRegister.Rdi] = handleOutAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
var handle = ReadUInt64(memory, handleOutAddress);
const ulong levelIdent = 0xA1;
ctx[CpuRegister.Rdi] = handle;
ctx[CpuRegister.Rsi] = levelIdent;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
ctx[CpuRegister.Rdx] = 0x1111;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
Assert.True(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
handle,
out var firstLevel));
Assert.True(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
handle,
out var secondLevel));
Assert.Equal((ushort)0, firstLevel.Flags);
Assert.Equal(0x1111UL, firstLevel.UserData);
Assert.Equal(firstLevel, secondLevel);
ctx[CpuRegister.Rsi] = levelIdent;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
const ulong edgeIdent = 0xA2;
ctx[CpuRegister.Rsi] = edgeIdent;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelAddUserEventEdge(ctx));
ctx[CpuRegister.Rdx] = 0x2222;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
Assert.True(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
handle,
out var edge));
Assert.Equal(
KernelEventQueueCompatExports.KernelEventFlagClear,
edge.Flags);
Assert.Equal(0x2222UL, edge.UserData);
Assert.False(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
handle,
out _));
ctx[CpuRegister.Rdi] = handle;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
}
[Fact]
public void DeletingLevelRegistrationClearsItsReadyState()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
const ulong handleOutAddress = BaseAddress + 0x100;
ctx[CpuRegister.Rdi] = handleOutAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
var handle = ReadUInt64(memory, handleOutAddress);
ctx[CpuRegister.Rdi] = handle;
ctx[CpuRegister.Rsi] = 0xB1;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
ctx[CpuRegister.Rdx] = 0x3333;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
Assert.False(
KernelEventQueueCompatExports.TryReservePendingEventForTest(
handle,
out _));
ctx[CpuRegister.Rdi] = handle;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
@@ -133,4 +517,24 @@ public sealed class AgcEventQueueTests
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static ulong CreateEqueue(
CpuContext ctx,
FakeCpuMemory memory,
ulong handleOutAddress)
{
ctx[CpuRegister.Rdi] = handleOutAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
return ReadUInt64(memory, handleOutAddress);
}
private static void DeleteEqueue(CpuContext ctx, ulong handle)
{
ctx[CpuRegister.Rdi] = handle;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
}
}
@@ -0,0 +1,49 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using System.Linq;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcLabelProducerRetentionTests
{
[Fact]
public void ProducerHistoryCompactionNeverEvictsActiveRecords()
{
var entries = new List<(string Name, bool Completed)>
{
("active-a", false),
("complete-a", true),
("complete-b", true),
("active-b", false),
("complete-c", true),
};
var removed = AgcExports.CompactCompletedEntries(
entries,
static entry => entry.Completed,
targetCount: 2);
Assert.Equal(3, removed);
Assert.Equal(
["active-a", "active-b"],
entries.Select(static entry => entry.Name));
}
[Fact]
public void ProducerHistoryMayExceedSoftBoundWhileAllRecordsAreActive()
{
var entries = new List<bool> { false, false, false };
var removed = AgcExports.CompactCompletedEntries(
entries,
static completed => completed,
targetCount: 1);
Assert.Equal(0, removed);
Assert.Equal(3, entries.Count);
}
}
@@ -0,0 +1,220 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
// sceAgcDriverSubmitAcb takes the compute queue's owner handle in rdi. That handle is the
// eventId the guest registers with sceAgcDriverAddEqEvent, so an ACB reaching its queue fence
// must deliver a graphics-filter completion event under that exact ident. UE 4.27's dynamic
// resolution heuristic parks the game thread on that interrupt; without it the render side
// never publishes GPU timings and the title deadlocks.
[Collection(GraphicsEventQueueStateCollection.Name)]
public sealed class AgcSubmitCompletionEventTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const int MemorySize = 0x2000;
private const ulong HandleOutAddress = BaseAddress + 0x100;
private const ulong EventsAddress = BaseAddress + 0x200;
private const ulong OutCountAddress = BaseAddress + 0x300;
private const ulong TimeoutAddress = BaseAddress + 0x400;
private const ulong PacketAddress = BaseAddress + 0x500;
[Fact]
public void DriverSubmitAcb_DeliversCompletionEventUnderOwnerHandleIdent()
{
// Deliberately unusual so a graphics registration from a sibling test cannot alias it:
// the kernel event registry is process-wide static state.
const ulong ownerHandle = 0x5EA1;
const ulong userData = 0xC0FF_EE00_1234_5678;
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var equeue = CreateEqueue(ctx, memory);
try
{
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
equeue,
ownerHandle,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData));
SubmitEmptyAcb(ctx, memory, ownerHandle);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
WaitEqueue(ctx, memory, equeue));
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x00));
Assert.Equal(
KernelEventQueueCompatExports.KernelEventFilterGraphics,
ReadInt16(memory, EventsAddress + 0x08));
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x10));
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
}
finally
{
DeleteEqueue(ctx, equeue);
}
}
// Delivery is registration-gated: a title that never registered the ACB owner handle as a
// graphics eventId must not observe a spurious completion interrupt.
[Fact]
public void DriverSubmitAcb_WithoutMatchingRegistration_DeliversNothing()
{
const ulong ownerHandle = 0x5EA2;
const ulong unrelatedEventId = 0x5EA3;
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var equeue = CreateEqueue(ctx, memory);
try
{
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
equeue,
unrelatedEventId,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData: 0));
SubmitEmptyAcb(ctx, memory, ownerHandle);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
WaitEqueue(ctx, memory, equeue));
Assert.Equal(0u, ReadUInt32(memory, OutCountAddress));
}
finally
{
DeleteEqueue(ctx, equeue);
}
}
// The graphics queue keeps its own completion ident (0); an ACB owner handle must not be
// able to wake a graphics-queue completion registration or vice versa.
[Fact]
public void DriverSubmitDcb_DeliversCompletionEventUnderGraphicsIdent()
{
const ulong graphicsCompletionIdent = 0;
const ulong userData = 0xABCD_0000_0000_1111;
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var ctx = new CpuContext(memory, Generation.Gen5);
var equeue = CreateEqueue(ctx, memory);
try
{
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
equeue,
graphicsCompletionIdent,
KernelEventQueueCompatExports.KernelEventFilterGraphics,
userData));
WriteEmptySubmitPacket(memory);
ctx[CpuRegister.Rdi] = PacketAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.DriverSubmitDcb(ctx));
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
WaitEqueue(ctx, memory, equeue));
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
Assert.Equal(graphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00));
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
}
finally
{
DeleteEqueue(ctx, equeue);
}
}
private static void SubmitEmptyAcb(CpuContext ctx, FakeCpuMemory memory, ulong ownerHandle)
{
WriteEmptySubmitPacket(memory);
ctx[CpuRegister.Rdi] = ownerHandle;
ctx[CpuRegister.Rsi] = PacketAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.DriverSubmitAcb(ctx));
}
// A zero-dword submission parses as immediately complete, so the queue reaches its fence
// without needing a live GPU backend.
private static void WriteEmptySubmitPacket(FakeCpuMemory memory)
{
WriteUInt64(memory, PacketAddress, 0);
WriteUInt32(memory, PacketAddress + 8, 0);
}
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
{
ctx[CpuRegister.Rdi] = HandleOutAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
return ReadUInt64(memory, HandleOutAddress);
}
// The kernel event registry is process-wide static state and deleting the queue drops its
// registrations, so sibling suites that assert over every graphics registration stay clean.
private static void DeleteEqueue(CpuContext ctx, ulong equeue)
{
ctx[CpuRegister.Rdi] = equeue;
_ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx);
}
private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue)
{
WriteUInt64(memory, TimeoutAddress, 0);
ctx[CpuRegister.Rdi] = equeue;
ctx[CpuRegister.Rsi] = EventsAddress;
ctx[CpuRegister.Rdx] = 4;
ctx[CpuRegister.Rcx] = OutCountAddress;
ctx[CpuRegister.R8] = TimeoutAddress;
return KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
private static short ReadInt16(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[2];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadInt16LittleEndian(buffer);
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}
@@ -114,6 +114,96 @@ public sealed class Gen5VertexInputSpirvTests
}
}
[Fact]
public void AliasedFetchInstructionsShareOneAttributeLocation()
{
// Metal caps a vertex function at 31 attributes, so every fetch that
// reads one guest stream view must resolve to that view's single
// location instead of declaring its own.
var firstFetch = CreateVertexFetch(0);
var secondFetch = CreateVertexFetch(4);
var end = new Gen5ShaderInstruction(
8,
Gen5ShaderEncoding.Sopp,
"SEndpgm",
[],
[],
[],
null);
var state = new Gen5ShaderState(
new Gen5ShaderProgram(0, [firstFetch, secondFetch, end]),
[],
null);
var registers = new uint[256];
var data = new byte[16];
var evaluation = new Gen5ShaderEvaluation(
registers,
registers,
[],
[],
VertexInputs:
[
new Gen5VertexInputBinding(
0,
0,
4,
10,
0,
0x1000,
4,
0,
data,
data.Length,
DataPooled: false,
AliasPcs: [4u]),
]);
Assert.True(
Gen5SpirvTranslator.TryCompileVertexShader(
state,
evaluation,
out var shader,
out var error),
error);
var module = ParseModule(shader.Spirv);
var locations = module
.Where(candidate =>
candidate.Opcode == SpirvOp.Decorate &&
candidate.Operands.Length >= 3 &&
candidate.Operands[1] == (uint)SpirvDecoration.Location)
.ToArray();
var inputVariable = Assert.Single(locations).Operands[0];
// Both fetches must read that variable; an unaliased second fetch would
// fall through to the generic buffer path and leave only one load.
Assert.Equal(
2,
module.Count(candidate =>
candidate.Opcode == SpirvOp.Load &&
candidate.Operands.Length >= 3 &&
candidate.Operands[2] == inputVariable));
}
private static Gen5ShaderInstruction CreateVertexFetch(uint pc) =>
new(
pc,
Gen5ShaderEncoding.Mubuf,
"BufferLoadFormatXyzw",
[],
[],
[],
new Gen5BufferMemoryControl(
4,
5,
0,
0,
0,
IndexEnabled: true,
OffsetEnabled: false,
Glc: false,
Slc: false));
private static IReadOnlyList<ParsedInstruction> ParseModule(byte[] spirv)
{
var instructions = new List<ParsedInstruction>();
@@ -0,0 +1,71 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class GpuWaitRegistryProducedRetentionTests
{
private const ulong WatchedLabel = 0x7020_0000_1000UL;
// A suspended DCB whose label the guest has recycled can only be released by
// replaying the value a real producer wrote to that label. Recording enough
// unrelated producers to cross the table's soft bound must not discard that
// value, or the waiter is stranded and the graphics queue never resumes.
[Fact]
public void ProducedValueSurvivesBoundCrossingWhileAWaiterWatchesIt()
{
GpuWaitRegistry.Clear();
var memory = new object();
GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel));
Assert.True(GpuWaitRegistry.RecordProduced(memory, WatchedLabel, 1));
// Cross the soft bound with labels nobody is waiting on.
for (var i = 0; i < 9000; i++)
{
GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1);
}
// The guest has since recycled the label, so its memory no longer holds
// the produced value — the registry's record is the only way back.
var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1);
Assert.NotNull(broken);
Assert.Contains(broken!, waiter => waiter.WaitAddress == WatchedLabel);
GpuWaitRegistry.Clear();
}
[Fact]
public void UnwatchedProducedValuesArePrunedAtTheBound()
{
GpuWaitRegistry.Clear();
var memory = new object();
for (var i = 0; i < 9000; i++)
{
GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1);
}
// Nothing was watching any of them, so a waiter registered afterwards on
// a pruned label has no produced value to replay and stays suspended.
GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel));
var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1);
Assert.Null(broken);
GpuWaitRegistry.Clear();
}
private static GpuWaitRegistry.WaitingDcb NewWaiter(object memory, ulong address) => new()
{
WaitAddress = address,
ReferenceValue = 1,
Mask = 0xFFFF_FFFFUL,
CompareFunction = 3, // equal
Memory = memory,
QueueName = "dcb.graphics",
RegisteredTicks = 0,
};
}
@@ -0,0 +1,78 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Ampr;
using Xunit;
namespace SharpEmu.Libs.Tests.Ampr;
public class AmprFileRegistryTests
{
[Fact]
public void ComputeFileId_matches_utf8_fnv1a()
{
const string relative = "CoreData/foo/bar.bin";
Assert.Equal(FnvUtf8("$/" + relative), AmprFileRegistry.ComputeFileId("$/" + relative));
Assert.Equal(FnvUtf8("/app0/" + relative), AmprFileRegistry.ComputeFileId("/app0/" + relative));
Assert.Equal(FnvUtf8("app0/" + relative), AmprFileRegistry.ComputeFileId("app0/" + relative));
Assert.Equal(FnvUtf8(relative), AmprFileRegistry.ComputeFileId(relative));
}
[Fact]
public void RegisterApp0Relative_publishes_same_ids_as_string_hashes()
{
AmprFileRegistry.ClearForTests();
const string relative = "misc/loadouts/test.txt";
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test", relative);
AmprFileRegistry.RegisterApp0RelativeForTests(relative, host);
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId(relative), out var d));
Assert.Equal(host, a);
Assert.Equal(host, b);
Assert.Equal(host, c);
Assert.Equal(host, d);
}
[Fact]
public void Register_publishes_all_app0_path_aliases()
{
AmprFileRegistry.ClearForTests();
const string relative = "scripts/cp11/cp11main.script";
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test2", relative);
AmprFileRegistry.Register("$/" + relative, host);
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
Assert.True(AmprFileRegistry.TryGetHostPath(
AmprFileRegistry.ComputeFileId(relative), out var d));
Assert.Equal(host, a);
Assert.Equal(host, b);
Assert.Equal(host, c);
Assert.Equal(host, d);
}
private static uint FnvUtf8(string text)
{
const uint offset = 2166136261;
const uint prime = 16777619;
var hash = offset;
foreach (var b in System.Text.Encoding.UTF8.GetBytes(text))
{
hash ^= b;
hash *= prime;
}
return hash;
}
}

Some files were not shown because too many files have changed in this diff Show More