Merge remote-tracking branch 'origin/master' into jit-android-catchup-gv7

This commit is contained in:
jpolo1224
2026-08-16 10:52:15 -04:00
11 changed files with 526 additions and 74 deletions
+10
View File
@@ -38,6 +38,16 @@
#include <immintrin.h>
#elif defined(ARCH_ARM64)
// AArch64 has no aligned/unaligned load distinction to begin with: LDR Q and LD1
// take any address, and GSVector4i::load<aligned> ignores its own parameter and
// emits the same instruction either way. Leaving this undefined made the callers
// pay for a distinction that does not exist — a runtime address-and-pitch test
// per texture upload, dispatching into three template instantiations that
// compile to identical code, and for 32/16-bit columns a worse load strategy
// (eight combining 64-bit loads instead of four 128-bit loads and a swizzle).
#define FAST_UNALIGNED 1
#include <arm_neon.h>
#endif
+13 -4
View File
@@ -482,13 +482,22 @@ namespace R5900
// an MMIO handler page, or a physical address that does not exist --
// is marked unbacked; the line still caches and reports its flags,
// and loses its data on eviction (see the comment on CacheTag).
//
// The lookup goes through the physical map, not through the KSEG0
// alias of the page: KSEG0 is only 512 MB wide, so routing a
// physical page through it meant masking the tag to 29 bits, and
// every page at or above 0x20000000 then folded into the low half
// of the map and resolved to whatever lives there. A page past the
// end of the map folded onto ordinary RAM and the write-back went
// into it. vtlb_GetPhyPtr covers the whole 1 GB physical map and
// answers null both for a handler page and for an address off the
// end of it.
const u32 pageTag = cpuRegs.CP0.n.TagLo & ~static_cast<u32>(CacheTag::ALL_BITS);
const u32 alias = 0x80000000u | (pageTag & 0x1FFFFFFFu);
const VTLBVirtual vmv = vtlbdata.vmap[alias >> VTLB_PAGE_BITS];
const bool backed = !vmv.isHandler(alias);
void* const host = vtlb_GetPhyPtr(pageTag);
const bool backed = host != nullptr;
line.tag.setValidPFN(backed);
line.tag.setAddr(backed ? vmv.assumePtr(alias) : static_cast<uptr>(pageTag));
line.tag.setAddr(backed ? reinterpret_cast<uptr>(host) : static_cast<uptr>(pageTag));
line.tag.rawValue &= ~CacheTag::ALL_FLAGS;
line.tag.rawValue |= (cpuRegs.CP0.n.TagLo & CacheTag::ALL_FLAGS);
+5
View File
@@ -144,6 +144,11 @@ alignas(16) extern tIPU_BP g_BP;
MULTI_ISA_DEF(
extern void ipu_dither(const macroblock_rgb32& rgb32, macroblock_rgb16& rgb16, int dte);
// The scalar oracle ipu_dither()'s vector paths are written against. Exposed
// so the tests can hold whichever path this host selected to it; the emulator
// only ever reaches it through ipu_dither()'s own fallback arm.
extern void ipu_dither_reference(const macroblock_rgb32& rgb32, macroblock_rgb16& rgb16, int dte);
void IPUWorker();
)
+73 -3
View File
@@ -10,22 +10,29 @@
MULTI_ISA_UNSHARED_START
void ipu_dither_reference(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16, int dte);
#if defined(_M_X86)
void ipu_dither_sse2(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16, int dte);
#endif
#if defined(ARCH_ARM64)
void ipu_dither_neon(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16, int dte);
#endif
__ri void ipu_dither(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16, int dte)
{
#if defined(_M_X86)
ipu_dither_sse2(rgb32, rgb16, dte);
#elif defined(ARCH_ARM64)
ipu_dither_neon(rgb32, rgb16, dte);
#else
ipu_dither_reference(rgb32, rgb16, dte);
#endif
}
__ri void ipu_dither_reference(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16, int dte)
// Deliberately not inlineable: this is the semantic oracle the vector paths are
// written against, so the tests need a symbol to call. (__ri collapses to
// __forceinline in Release, which would leave nothing to link to.)
void ipu_dither_reference(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16, int dte)
{
if (dte) {
// I'm guessing values are rounded down when clamping.
@@ -121,4 +128,67 @@ __ri void ipu_dither_sse2(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16
#endif
#if defined(ARCH_ARM64)
// dither_coefficient[] above with the sign folded into the choice of operation:
// a positive cell goes in the add table, a negative one goes in the sub table at
// its magnitude, and the other table holds zero for that lane. Saturating byte
// arithmetic then gives the reference's clamp to [0, 255] for free.
//
// One row of the source matrix covers four pixel columns and the pattern repeats
// every four, so each entry is that row's four cells laid out four times — lane
// k is the cell for pixel k, which is what a deinterleaved row wants.
alignas(16) static const u8 dither_add_matrix[4][16] = {
{0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1},
{2, 0, 3, 0, 2, 0, 3, 0, 2, 0, 3, 0, 2, 0, 3, 0},
{0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0},
{3, 0, 2, 0, 3, 0, 2, 0, 3, 0, 2, 0, 3, 0, 2, 0},
};
alignas(16) static const u8 dither_sub_matrix[4][16] = {
{4, 0, 3, 0, 4, 0, 3, 0, 4, 0, 3, 0, 4, 0, 3, 0},
{0, 2, 0, 1, 0, 2, 0, 1, 0, 2, 0, 1, 0, 2, 0, 1},
{3, 0, 4, 0, 3, 0, 4, 0, 3, 0, 4, 0, 3, 0, 4, 0},
{0, 1, 0, 2, 0, 1, 0, 2, 0, 1, 0, 2, 0, 1, 0, 2},
};
__ri void ipu_dither_neon(const macroblock_rgb32 &rgb32, macroblock_rgb16 &rgb16, int dte)
{
const uint8x16_t alpha_test = vdupq_n_u8(0x40);
for (int i = 0; i < 16; ++i) {
// NEON deinterleaves on the load, so a whole 16-pixel row arrives already
// split one register per channel. The SSE path needs six unpacks to reach
// the same place because x86 has no equivalent load.
uint8x16x4_t px = vld4q_u8(&rgb32.c[i][0].r);
if (dte) {
const uint8x16_t add = vld1q_u8(dither_add_matrix[i & 3]);
const uint8x16_t sub = vld1q_u8(dither_sub_matrix[i & 3]);
px.val[0] = vqsubq_u8(vqaddq_u8(px.val[0], add), sub);
px.val[1] = vqsubq_u8(vqaddq_u8(px.val[1], add), sub);
px.val[2] = vqsubq_u8(vqaddq_u8(px.val[2], add), sub);
}
const uint8x16_t r = vshrq_n_u8(px.val[0], 3);
const uint8x16_t g = vshrq_n_u8(px.val[1], 3);
const uint8x16_t b = vshrq_n_u8(px.val[2], 3);
const uint8x16_t a = vceqq_u8(px.val[3], alpha_test);
// r:5 g:5 b:5 a:1, least significant field first. The alpha compare widens
// to 0x00FF, and 0x00FF << 15 truncates to exactly the 0x8000 top bit.
const uint16x8_t lo = vorrq_u16(
vorrq_u16(vmovl_u8(vget_low_u8(r)), vshlq_n_u16(vmovl_u8(vget_low_u8(g)), 5)),
vorrq_u16(vshlq_n_u16(vmovl_u8(vget_low_u8(b)), 10), vshlq_n_u16(vmovl_u8(vget_low_u8(a)), 15)));
const uint16x8_t hi = vorrq_u16(
vorrq_u16(vmovl_high_u8(r), vshlq_n_u16(vmovl_high_u8(g), 5)),
vorrq_u16(vshlq_n_u16(vmovl_high_u8(b), 10), vshlq_n_u16(vmovl_high_u8(a), 15)));
vst1q_u16(reinterpret_cast<u16 *>(&rgb16.c[i][0]), lo);
vst1q_u16(reinterpret_cast<u16 *>(&rgb16.c[i][8]), hi);
}
}
#endif
MULTI_ISA_UNSHARED_END
+14
View File
@@ -3347,6 +3347,20 @@ void VMManager::CheckForMiscConfigChanges(const Pcsx2Config& old_config)
ShutdownDiscordPresence();
}
// PINE is the same shape of thing as the Discord integration above -- an optional external
// service whose entire lifecycle is one bool plus a port -- but it was never wired to the
// settings path. ReloadPINE() had exactly two callers, CPUThreadInitialize() and
// UpdateDiscDetails(), so a toggle only took effect at the next app start or game change.
// On a handheld that reads as a broken switch: the UI says enabled, nothing is listening,
// and there is no separate window to restart into to discover otherwise.
//
// Called unconditionally rather than gated on old_config, because ReloadPINE() compares the
// request against the LIVE server -- is one initialized, and on which slot -- which is
// strictly stronger than a config diff. It early-returns when they already agree, and it
// recovers a server that lost an earlier bind (the port still in TIME_WAIT from a previous
// run is the usual way that happens) instead of trusting a config value that never changed.
ReloadPINE();
if (HasValidVM() && (EmuConfig.EnableThreadPinning != old_config.EnableThreadPinning ||
(s_thread_affinities_set && EmuConfig.Speedhacks.vuThread != old_config.Speedhacks.vuThread)))
{
@@ -289,6 +289,20 @@ object ConfigStore {
fun save(scope: SettingsScope, serial: String?, updated: Settings, previous: Settings? = null) {
if (scope == SettingsScope.Game && serial != null) {
val global = loadGlobal()
// Process-wide fields have to go to global even from a Game-scope save, because the
// per-game file structurally cannot hold them. PINE is one server for the whole
// process, so Settings.merge pins it to the global value and Settings.diff never
// emits the key -- both deliberate. The consequence was that toggling PINE from the
// in-game menu, which saves in Game scope, wrote it NOWHERE: the override file
// refuses the key and global was not being written. The switch stayed on only
// because saveSettings had already updated the in-memory Settings, so it read as
// "enabled" until the process restarted and the store answered false again.
//
// Promote just those fields, by copying them onto global rather than saving
// `updated` wholesale -- `updated` is the game's resolved settings, and writing all
// of it to global would leak every per-game value into the global layer.
if (updated.pineEnabled != global.pineEnabled || updated.pineSlot != global.pineSlot)
saveGlobal(global.copy(pineEnabled = updated.pineEnabled, pineSlot = updated.pineSlot))
val overrides = Settings.diff(global, updated)
// Every field, so a pinned key can be given its CURRENT value even when that
// value equals global's (the diff above necessarily omits it).
@@ -91,6 +91,11 @@ private const val STICK_DEAD = 0.15f
// pads; the value is re-normalized past it (see sendTrigger) so pressure ramps smoothly
// from 0 instead of flickering on/off at a hard threshold — the jitter those pads showed.
private const val TRIGGER_DEAD = 0.06f
// Travel past which a trigger counts as the L2/R2 BUTTON being held rather than a pressure
// value — what the bind capture records, what fires a trigger-bound hotkey, and what makes a
// held trigger a combo modifier. Well above TRIGGER_DEAD: pressure ramps from a brush, but
// "pressed" should mean a deliberate press.
private const val TRIGGER_DIGITAL_THRESHOLD = 0.5f
// Threshold past which a stick remapped to D-pad / face buttons registers as a
// digital press. Higher than STICK_DEAD so a resting/wobbling stick doesn't fire.
private const val STICK_DIGITAL_THRESHOLD = 0.5f
@@ -3603,17 +3608,11 @@ open class MainActivityRuntime : ComponentActivity() {
// D-pad never registered while face-button mapping (different codes)
// worked fine.
dispatchDpadCombined(ev, port)
// Analog triggers (L2/R2). Xbox / DualShock / most modern pads
// report these as 0..1 motion-axis values, not Key.ButtonL2/R2
// key events, so the direct key path never sees them.
// AXIS_LTRIGGER/RTRIGGER is the modern path; some controllers
// (older Moga, certain BT mappings) report via AXIS_BRAKE/GAS
// instead — take the max so we handle whichever the device
// actually emits without double-driving when both are present.
sendTrigger(ev, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE,
KeyEvent.KEYCODE_BUTTON_L2, port)
sendTrigger(ev, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS,
KeyEvent.KEYCODE_BUTTON_R2, port, axisC = rightTriggerExtraAxis(ev.deviceId))
// Analog triggers (L2/R2). Xbox / DualShock / most modern pads report these as
// 0..1 motion-axis values, not KEYCODE_BUTTON_L2/R2 key events, so the direct key
// path never sees them. Which axes that means per device is triggerAxes' job.
sendTrigger(ev, left = true, port = port)
sendTrigger(ev, left = false, port = port)
// Physical STICK DIRECTIONS bound to a PS2 control via the "(send)"
// rows — e.g. R-Stick Down bound to send Square. The analog "(send)"
// targets contribute to the merge layer like every other writer.
@@ -3702,15 +3701,48 @@ open class MainActivityRuntime : ComponentActivity() {
}
}
// Extra RT axis for pads that report the right trigger on AXIS_RZ (AYANEO Xbox mode) instead of
// RTRIGGER/GAS. Only when RZ is a 0..1 range (a real stick-Y is -1..1), so standard pads are
// untouched. -1 = no such axis. Cached — InputDevice.getDevice is a binder call.
private val rightTriggerAxisCache = HashMap<Int, Int>()
private fun rightTriggerExtraAxis(deviceId: Int): Int = rightTriggerAxisCache.getOrPut(deviceId) {
val rz = runCatching { InputDevice.getDevice(deviceId)?.getMotionRange(MotionEvent.AXIS_RZ) }.getOrNull()
if (rz != null && rz.min >= 0f) MotionEvent.AXIS_RZ else -1
// Which axes carry the [left]/right trigger on this pad: LTRIGGER/RTRIGGER (modern),
// BRAKE/GAS (older Moga, some BT mappings), or plain Z/RZ when Android has no vendor key
// layout for the pad and passes raw HID through — AYANEO Xbox mode on the right (#394), a
// plain Xbox controller on BOTH. Only a 0..1 range qualifies (a stick axis spans -1..1), so
// a standard pad's right stick is never taken for a trigger; -1 = absent. The left side had
// no such fallback, so on those pads LT was read by nothing at all. One resolver for capture
// AND gameplay, so a bind can't capture an axis gameplay doesn't read. Cached:
// InputDevice.getDevice is a binder call and motion events are far too frequent for it.
private val triggerAxisCache = HashMap<Int, Triple<Int, Int, Int>>()
private fun triggerAxes(deviceId: Int, left: Boolean): Triple<Int, Int, Int> =
triggerAxisCache.getOrPut(deviceId * 2 + (if (left) 0 else 1)) {
val raw = if (left) MotionEvent.AXIS_Z else MotionEvent.AXIS_RZ
val range = runCatching { InputDevice.getDevice(deviceId)?.getMotionRange(raw) }.getOrNull()
Triple(
if (left) MotionEvent.AXIS_LTRIGGER else MotionEvent.AXIS_RTRIGGER,
if (left) MotionEvent.AXIS_BRAKE else MotionEvent.AXIS_GAS,
if (range != null && range.min >= 0f) raw else -1,
)
}
/** 0..1 travel on the [left]/right trigger highest of the candidate axes, negatives
* clamped (some pads idle an unused trigger axis at -1). Returns **-1 when the pad has no
* trigger axis on that side**, which is not the same as one resting at zero: a Switch Pro
* Controller sends L2/R2 as key events only, and reading its absent axes as 0.0 once wrote
* "released" every motion event, cancelling a held R2 whenever the stick moved. */
private fun triggerTravel(ev: MotionEvent, left: Boolean): Float {
val (a, b, c) = triggerAxes(ev.deviceId, left)
if (!deviceHasAxis(ev.deviceId, a) && !deviceHasAxis(ev.deviceId, b) &&
!deviceHasAxis(ev.deviceId, c))
return -1f
return maxOf(
maxOf(ev.getAxisValue(a), ev.getAxisValue(b)),
if (c >= 0) ev.getAxisValue(c) else 0f,
).coerceIn(0f, 1f)
}
/** The keycode a trigger stands in for. The binding model is keyed on keycodes and most
* pads give their triggers none, so every trigger path capture and gameplay refers to
* them by the code a key-emitting pad would send. */
private fun triggerKeyCode(left: Boolean): Int =
if (left) KeyEvent.KEYCODE_BUTTON_L2 else KeyEvent.KEYCODE_BUTTON_R2
private var lastStickProbeMs = 0L
private fun debugStickProbe(ev: MotionEvent) {
if (!prefs.getBoolean("debug.stickLog", false)) return
@@ -4101,6 +4133,14 @@ open class MainActivityRuntime : ComponentActivity() {
// here is why its directions could never be bound.
val (capRightX, capRightY) = rightStickAxes(ev.deviceId)
captureStickCode(ev, capRightX, capRightY, false).takeIf { it != 0 }?.let { want.add(it) }
// Analog TRIGGERS, same treatment: on a pad whose triggers are axis-only (an Xbox
// controller, and most modern pads) the capture saw nothing at all when one was pulled
// — and since this method consumes the motion, not even a UI twitch to explain why.
// Standing in the keycode a key-emitting pad would send makes the trigger an ordinary
// button downstream, and gameplay resolves that same code back (sendTrigger).
for (left in booleanArrayOf(true, false)) {
if (triggerTravel(ev, left) > TRIGGER_DIGITAL_THRESHOLD) want.add(triggerKeyCode(left))
}
captureHatX = dx
captureHatY = dy
val now = SystemClock.uptimeMillis()
@@ -4457,7 +4497,7 @@ open class MainActivityRuntime : ComponentActivity() {
// Edge: fire a hotkey with this direction as its MAIN key —
// combo-aware (e.g. "hold Select + push R-Stick Up"), falling
// back to a plain single-direction binding.
ControllerMappings.matchHotkey(code, heldKeys)?.let { runStickHotkey(it) }
ControllerMappings.matchHotkey(code, heldKeys)?.let { runEdgeHotkey(it) }
}
} else {
heldKeys.remove(code)
@@ -4466,11 +4506,12 @@ open class MainActivityRuntime : ComponentActivity() {
}
}
/** Fire an ARMSX2 hotkey from a non-key source (a CUSTOM stick direction crossing
/** Fire an ARMSX2 hotkey from a non-key source (a stick direction or a trigger crossing
* its threshold edge-triggered, treated as a single press). Hold-type hotkeys
* (FAST_FORWARD hold, PRESSURE_MOD) are no-ops here a stick edge has no hold
* semantics; the rest mirror the one-shot actions in dispatchKeyEvent. */
private fun runStickHotkey(h: ControllerMappings.SysHotkey) {
* (FAST_FORWARD hold, PRESSURE_MOD) are no-ops here: a stick edge has no hold semantics,
* and sendTrigger handles them itself on both edges. The rest mirror the one-shot
* actions in dispatchKeyEvent. */
private fun runEdgeHotkey(h: ControllerMappings.SysHotkey) {
when (h) {
ControllerMappings.SysHotkey.MENU -> InGameOverlay.toggle()
ControllerMappings.SysHotkey.SCREENSHOT -> com.armsx2.Screenshots.capture(applicationContext)
@@ -4527,7 +4568,7 @@ open class MainActivityRuntime : ComponentActivity() {
ControllerMappings.hotkeyForStickCode(code)?.let { hk ->
val held = stickHotkeyHeld[port]
if (mag > STICK_DIGITAL_THRESHOLD) {
if (held.add(code)) runStickHotkey(hk)
if (held.add(code)) runEdgeHotkey(hk)
} else {
held.remove(code)
}
@@ -4674,38 +4715,64 @@ open class MainActivityRuntime : ComponentActivity() {
}
}
private fun sendTrigger(event: MotionEvent, axisA: Int, axisB: Int, code: Int, port: Int, axisC: Int = -1) {
// A pad with NO analog trigger axis at all — a Nintendo Switch Pro Controller, or an
// 8BitDo Pro in Switch mode, which enumerates as one (vendor 0x057e) — delivers L2/R2
// ONLY as KEYCODE_BUTTON_L2/R2 key events. Its axis list is just X/Y, Z/RZ and the HAT.
//
// Reading the absent trigger axes yields 0.0, so the lines below wrote "trigger
// released" on EVERY motion event. Hold R2 and move the stick and the stick's own
// motion event cancelled the held trigger — "R2 and the stick can't be used at the
// same time", which kills racing games. Buttons were unaffected because nothing on the
// motion path writes them; only L2/R2 have a motion-side writer. Same shape as the
// D-pad "last write wins" bug handled in dispatchDpadCombined.
//
// When the device has none of these axes, leave the key path in sole charge.
if (!deviceHasAxis(event.deviceId, axisA) && !deviceHasAxis(event.deviceId, axisB) &&
!deviceHasAxis(event.deviceId, axisC))
return
// Triggers past TRIGGER_DIGITAL_THRESHOLD, per unified pad slot: edge state for
// trigger-bound hotkeys, so each press fires once and re-arms on release.
private val triggerHotkeyHeld = Array(8) { HashSet<Int>() }
private fun sendTrigger(event: MotionEvent, left: Boolean, port: Int) {
// -1 = no trigger axis on this side; its L2/R2 is a key event, key path owns it.
val raw = triggerTravel(event, left)
if (raw < 0f) return
val code = triggerKeyCode(left)
val held = triggerHotkeyHeld[port]
val pressed = raw > TRIGGER_DIGITAL_THRESHOLD
// Mirror into heldKeys so a held trigger can be a combo MODIFIER, exactly as it is on a
// pad whose triggers send key events. Cleared on OUR release edge only — a pad that
// reports its triggers both ways must not have the key path's hold wiped by a motion
// event that happens to read the axis low.
if (pressed) heldKeys.add(code)
if (pressed != held.contains(code)) {
if (pressed) held.add(code) else { held.remove(code); heldKeys.remove(code) }
// Triggers now reach the Hotkeys tab's capture like any other button, so they have
// to be able to fire one here. Hold-type hotkeys act on both edges (a trigger has a
// real release, unlike a stick edge); the rest fire on the press. Matching on
// release re-adds the code, as the key path does, so a combo still resolves.
ControllerMappings.matchHotkey(code, if (pressed) heldKeys else heldKeys + code)?.let { hk ->
when (hk) {
ControllerMappings.SysHotkey.FAST_FORWARD -> {
if (pressed) fastForwardToggleActive = false
runCatching {
NativeApp.speedhackLimitermode(if (pressed) ffLimiterMode() else baseLimiterMode())
}
}
ControllerMappings.SysHotkey.PRESSURE_MOD ->
com.armsx2.ui.touch.TouchControls.pressureModifierHeld.value = pressed
ControllerMappings.SysHotkey.GYRO_HOLD -> gyroActive.value = pressed
else -> if (pressed) runEdgeHotkey(hk)
}
}
// Macros are keyed on the physical code too, and the Pad tab now lets a trigger be
// captured for one. Same both-edges firing as dispatchGameplayKey.
com.armsx2.ui.touch.TouchControls.macroForPhysicalCode(code)?.let { macro ->
com.armsx2.ui.touch.TouchControls.fireMacro(macro, "pad$port", pressed) { c, p ->
sendKeyAction(if (p) KeyEventType.KeyDown else KeyEventType.KeyUp, c, port)
}
}
}
// A trigger bound to a hotkey or a macro doesn't also drive the pad — the precedence
// the key path and emitCustom already apply. The hotkey match is combo-aware, so a
// trigger that is merely a MODIFIER keeps working as L2/R2.
if (ControllerMappings.matchHotkey(code, heldKeys) != null) return
if (com.armsx2.ui.touch.TouchControls.macroForPhysicalCode(code) != null) return
// Pads report L2/R2 on AXIS_*TRIGGER or on AXIS_BRAKE/GAS — take the higher of
// the two, clamping negatives (some non-Xbox pads idle an unused trigger axis at
// -1). Then apply the SMALL trigger deadzone and re-normalize the remaining range
// to 0..1, so pressure ramps smoothly from zero to full instead of flicking on/off
// around the old hard 15% stick-deadzone boundary (the jitter non-Xbox pads showed)
// — and the low 15% of travel is no longer wasted.
// Honor the L2/R2 binding: triggers arrive as motion axes, never through the
// keycode binding path, so clearing/remapping them in the Pad tab was ignored.
// Resolve the physical trigger keycode to its mapped PS2 target — null = cleared,
// so the trigger is disabled; otherwise drive the resolved (possibly remapped) code.
val target = ControllerMappings.targetForPhysical(code, port) ?: return
val raw = maxOf(
maxOf(event.getAxisValue(axisA), event.getAxisValue(axisB)),
if (axisC >= 0) event.getAxisValue(axisC) else 0f,
).coerceIn(0f, 1f)
// Deadzone off the bottom, re-normalized, so pressure ramps from zero instead of
// flicking on/off at a hard threshold (the jitter non-Xbox pads showed).
val out = if (raw <= TRIGGER_DEAD) 0f else (raw - TRIGGER_DEAD) / (1f - TRIGGER_DEAD)
if (target in 110..123) {
// Trigger bound to a PS2 STICK direction ("(send)" rows): contribute the
+3
View File
@@ -20,6 +20,9 @@ endif()
# GS vertex front-end kernel oracle (arch-neutral).
add_subdirectory(gs)
# IPU colour-conversion oracle (arch-neutral: each host tests its own path).
add_subdirectory(ipu)
set(multi_isa_sources
GS/swizzle_test_main.cpp
+17
View File
@@ -0,0 +1,17 @@
# IPU colour-conversion oracle suite. ipu_dither() compiles to a different
# implementation per architecture and the three were never compared to each other;
# these hold whichever one this host selected to the scalar reference.
add_pcsx2_test(ipu_dither_tests
${CMAKE_CURRENT_SOURCE_DIR}/../StubHost.cpp
ipu_dither_tests.cpp
)
target_include_directories(ipu_dither_tests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../../../../pcsx2
)
target_link_libraries(ipu_dither_tests PUBLIC
PCSX2_FLAGS
PCSX2
common
)
+204
View File
@@ -0,0 +1,204 @@
// SPDX-FileCopyrightText: 2026 ARMSX2 Contributors
// SPDX-License-Identifier: GPL-3.0+
// Whichever dither path this host selects must be bit-identical to the scalar one.
//
// ipu_dither() has three implementations -- a scalar reference, an SSE2 path and a
// NEON path -- and the emulator picks one at compile time from the architecture.
// Nothing ever compared them. That is a bad shape for a function like this: it is
// the last step of MPEG colour conversion, so an error does not crash, it tints an
// FMV slightly, and nobody files that as a bug.
//
// The transform is per-pixel and depends on nothing but the pixel's four bytes and
// its position modulo four in each axis, so the whole input domain is small enough
// to cover directly rather than sampled. The sweeps below walk every byte value
// through every one of the sixteen dither cells; the randomised case exists on top
// of that only to catch a path that crosses channels, which a sweep holding r, g
// and b equal would not see.
//
// Both dither states matter. dte=0 is not a trivial passthrough -- it still packs
// 8888 down to 1555 and still derives alpha from a compare against 0x40 -- so a
// path that only got the dithered arm right would be half broken in exactly the
// mode most games use.
#include "IPU/IPU_MultiISA.h"
#include "GS/MultiISA.h"
#include "gtest/gtest.h"
#include <array>
#include <cstring>
#include <random>
namespace
{
constexpr int kDim = 16;
// A value no real conversion can produce: every output pixel has its top bit
// set only when alpha matched, so an all-ones block would require every pixel
// to be white and opaque at once. Seeding with it turns "this path skipped a
// pixel" into a mismatch instead of a silent pass on stale memory.
void PoisonOutput(macroblock_rgb16& rgb16)
{
std::memset(&rgb16, 0xFF, sizeof(rgb16));
}
// Returns the index of the first differing pixel, or -1 when the two agree.
int FirstMismatch(const macroblock_rgb16& a, const macroblock_rgb16& b)
{
u16 a_words[kDim * kDim];
u16 b_words[kDim * kDim];
std::memcpy(a_words, &a, sizeof(a_words));
std::memcpy(b_words, &b, sizeof(b_words));
for (int i = 0; i < kDim * kDim; i++)
{
if (a_words[i] != b_words[i])
return i;
}
return -1;
}
// Runs the host's selected path and the reference over the same input and
// reports the first pixel they disagree on, with enough context to place it in
// the dither matrix.
void ExpectMatchesReference(const macroblock_rgb32& rgb32, int dte, const char* what)
{
macroblock_rgb16 got;
macroblock_rgb16 want;
PoisonOutput(got);
PoisonOutput(want);
MULTI_ISA_SELECT(ipu_dither)(rgb32, got, dte);
MULTI_ISA_SELECT(ipu_dither_reference)(rgb32, want, dte);
const int bad = FirstMismatch(got, want);
if (bad < 0)
return;
const int row = bad / kDim;
const int col = bad % kDim;
const auto& src = rgb32.c[row][col];
u16 got_words[kDim * kDim];
u16 want_words[kDim * kDim];
std::memcpy(got_words, &got, sizeof(got_words));
std::memcpy(want_words, &want, sizeof(want_words));
ADD_FAILURE() << what << ": dte=" << dte << " first mismatch at row " << row
<< " col " << col << " (dither cell [" << (row & 3) << "][" << (col & 3) << "])"
<< "\n source rgba = " << int(src.r) << ", " << int(src.g) << ", "
<< int(src.b) << ", " << int(src.a)
<< "\n got = 0x" << std::hex << got_words[bad]
<< "\n want = 0x" << want_words[bad] << std::dec;
}
} // namespace
// Every byte value, through every dither cell, on the colour channels. Holding the
// three channels equal is what makes this a clean sweep of the dither arithmetic;
// channel independence is the randomised test's job.
TEST(IPUDither, ColourSweepMatchesReference)
{
for (int v = 0; v <= 255; v++)
{
macroblock_rgb32 rgb32;
for (int i = 0; i < kDim; i++)
{
for (int j = 0; j < kDim; j++)
{
rgb32.c[i][j].r = static_cast<u8>(v);
rgb32.c[i][j].g = static_cast<u8>(v);
rgb32.c[i][j].b = static_cast<u8>(v);
// Alternate the two alpha outcomes so neither is ever untested.
rgb32.c[i][j].a = ((i + j) & 1) ? 0x40 : 0x00;
}
}
ExpectMatchesReference(rgb32, 1, "colour sweep");
ExpectMatchesReference(rgb32, 0, "colour sweep");
}
}
// Alpha is a compare against 0x40, not a range, so the interesting inputs are the
// neighbours of that value as much as the extremes. Sweeping the whole byte covers
// both without having to guess.
TEST(IPUDither, AlphaSweepMatchesReference)
{
for (int v = 0; v <= 255; v++)
{
macroblock_rgb32 rgb32;
for (int i = 0; i < kDim; i++)
{
for (int j = 0; j < kDim; j++)
{
// Distinct per channel, so an alpha bug cannot hide behind a
// colour that happens to match.
rgb32.c[i][j].r = static_cast<u8>(j * 16);
rgb32.c[i][j].g = static_cast<u8>(i * 16);
rgb32.c[i][j].b = static_cast<u8>((i + j) * 8);
rgb32.c[i][j].a = static_cast<u8>(v);
}
}
ExpectMatchesReference(rgb32, 1, "alpha sweep");
ExpectMatchesReference(rgb32, 0, "alpha sweep");
}
}
// The saturating arithmetic only shows its edges where a cell pushes a value past
// a limit, and the cells reach +3 and -4. Pinning the exact boundary values means a
// path that clamps with the wrong operation fails here rather than on one unlucky
// random block.
TEST(IPUDither, SaturationEdgesMatchReference)
{
static constexpr std::array<u8, 10> kEdges = {0, 1, 2, 3, 4, 251, 252, 253, 254, 255};
for (const u8 lo : kEdges)
{
for (const u8 hi : kEdges)
{
macroblock_rgb32 rgb32;
for (int i = 0; i < kDim; i++)
{
for (int j = 0; j < kDim; j++)
{
rgb32.c[i][j].r = lo;
rgb32.c[i][j].g = hi;
rgb32.c[i][j].b = static_cast<u8>((j & 1) ? lo : hi);
rgb32.c[i][j].a = ((i + j) & 1) ? 0x40 : 0x3F;
}
}
ExpectMatchesReference(rgb32, 1, "saturation edges");
ExpectMatchesReference(rgb32, 0, "saturation edges");
}
}
}
// The sweeps all hold something constant across the block. This one holds nothing
// constant, which is what catches a path that reads the right bytes into the wrong
// channel -- a deinterleave that transposes r and b survives every test above.
TEST(IPUDither, RandomMacroblocksMatchReference)
{
std::mt19937 rng(20260814u); // fixed seed: a failing case must be reproducible
std::uniform_int_distribution<int> byte(0, 255);
for (int iter = 0; iter < 256; iter++)
{
macroblock_rgb32 rgb32;
for (int i = 0; i < kDim; i++)
{
for (int j = 0; j < kDim; j++)
{
rgb32.c[i][j].r = static_cast<u8>(byte(rng));
rgb32.c[i][j].g = static_cast<u8>(byte(rng));
rgb32.c[i][j].b = static_cast<u8>(byte(rng));
// Bias alpha towards the one value the compare cares about,
// otherwise it is almost never hit at random.
rgb32.c[i][j].a = (byte(rng) < 128) ? 0x40 : static_cast<u8>(byte(rng));
}
}
ExpectMatchesReference(rgb32, iter & 1, "random macroblock");
}
}
@@ -145,10 +145,14 @@ u32 Obs(int id, const char* name)
// process. Elsewhere the two callers skip.
//
// The candidates are 4K-aligned but none is 16K-aligned, so on a 16K-page
// kernel — Asahi, Apple Silicon, some Android — every one is rejected outright
// and the two callers always skip. Re-picking them 16K-aligned would need the
// tag/index constraints re-derived against the console capture, so it is left
// to whoever holds that data.
// kernel — Asahi, Apple Silicon, some Android — every one is rejected outright.
// Re-picking them 16K-aligned would need the tag/index constraints re-derived
// against the console capture, so it is left to whoever holds that data.
//
// A null return is therefore routine, not exceptional, and callers should treat
// the mapping as an optional negative control rather than a precondition. Only
// DxstgDirtyStaysInsideGuestMemory is wholly about the host page and has to
// skip; the write-back check keeps its guest-side half running everywhere.
void* MapAt(u32* chosen)
{
#if defined(MAP_FIXED_NOREPLACE)
@@ -439,14 +443,22 @@ TEST(EeCache2Console, DxstgWriteBackTargetsTheTaggedGuestPage)
constexpr u32 kTargetPage = 0x00129000;
constexpr u32 kTarget = kTargetPage + kSetIndex * 64;
// The host mapping is only the negative control: proof that the write-back
// did not ALSO reach the host page that happens to carry the same number.
// Everything else here is guest-side and needs nothing from the host, so it
// runs unconditionally. On a 16K-page kernel -- Asahi, Apple Silicon, some
// Android -- MapAt cannot honour a 4K-aligned request and returns null;
// only the control is skipped then, not the whole test.
u32 page = 0;
void* p = MapAt(&page);
if (!p)
GTEST_SKIP() << "could not map a page at any candidate host address";
ASSERT_EQ(page, kTargetPage) << "the control page moved; re-point kTargetPage";
std::memset(p, 0xEE, 0x1000);
const u32* host = reinterpret_cast<const u32*>(
static_cast<uptr>(page) + kSetIndex * 64);
const u32* host = nullptr;
if (p)
{
ASSERT_EQ(page, kTargetPage) << "the control page moved; re-point kTargetPage";
std::memset(p, 0xEE, 0x1000);
host = reinterpret_cast<const u32*>(
static_cast<uptr>(page) + kSetIndex * 64);
}
{
EeRecTestHarness h;
@@ -463,7 +475,8 @@ TEST(EeCache2Console, DxstgWriteBackTargetsTheTaggedGuestPage)
// 64 bytes of guest cache line, at the guest physical page the tag names.
EXPECT_EQ(memRead32(kTarget), 0x5A5A0009u);
EXPECT_EQ(memRead32(kTarget + 4), 0xDEADBEEFu);
EXPECT_EQ(host[0], 0xEEEEEEEEu) << "the write-back still reaches a host address";
if (host)
EXPECT_EQ(host[0], 0xEEEEEEEEu) << "the write-back still reaches a host address";
}
// Never filled, and filled-then-invalidated. Both used to be declined
@@ -471,7 +484,8 @@ TEST(EeCache2Console, DxstgWriteBackTargetsTheTaggedGuestPage)
for (const bool invalidate_first : {false, true})
{
SCOPED_TRACE(invalidate_first ? "filled then invalidated" : "never filled");
std::memset(p, 0xEE, 0x1000);
if (p)
std::memset(p, 0xEE, 0x1000);
EeRecTestHarness h;
resetCache();
memWrite32(kTarget, 0xA5A5A5A5u);
@@ -489,27 +503,52 @@ TEST(EeCache2Console, DxstgWriteBackTargetsTheTaggedGuestPage)
RunCacheOp(0x12, kProbeLine);
RunCacheOp(0x14, kProbeLine);
EXPECT_EQ(memRead32(kTarget), 0u) << "the cleared line did not write back";
EXPECT_EQ(host[0], 0xEEEEEEEEu) << "the write-back still reaches a host address";
if (host)
EXPECT_EQ(host[0], 0xEEEEEEEEu) << "the write-back still reaches a host address";
}
munmap(p, 0x1000);
if (p)
munmap(p, 0x1000);
}
// A DXSTG naming a page that does not resolve to plain guest memory leaves the
// line unbacked, so the write-back declines rather than dereferencing anything.
// 0x1FC00000-and-up is BIOS/unmapped territory at the top of the physical map.
//
// 0x60129000 is past the end of the physical map, and it is the page with
// teeth: the lookup used to keep only the low 29 bits of the tag, so this page
// folded onto 0x00129000 -- ordinary main RAM -- and wrote sixty-four bytes
// there. The witness turns "we did not fault" into "we did not write somewhere
// the guest never named". An SCPH-30001 agrees on that much: an eviction
// steered above the end of RAM puts nothing into RAM.
//
// This check used to name 0x1FFFF000, calling it unmapped. That is the last
// page of the 4 MB BIOS ROM at 0x1FC00000, so it is real backing memory and the
// declining branch never ran at all.
TEST(EeCache2Console, DxstgOnAnUnresolvablePageDeclinesTheWriteBack)
{
constexpr u32 kUnresolvablePage = 0x60129000u;
constexpr u32 kWitness = 0x00129000u + kSetIndex * 64;
EeRecTestHarness h;
resetCache();
memWrite32(kWitness, 0xA5A5A5A5u);
writeCache32(kProbeLine, 0x5A5A0009u);
cpuRegs.CP0.n.TagLo = 0x1FFFF000u | kFlagDirty | kFlagValid;
cpuRegs.CP0.n.TagLo = kUnresolvablePage | kFlagDirty | kFlagValid;
RunCacheOp(0x12, kProbeLine); // DXSTG
RunCacheOp(0x14, kProbeLine); // DXWBIN -- must be a no-op, not a store
// Reaching here without a fault is the assertion; the flags still round-trip.
EXPECT_EQ(memRead32(kWitness), 0xA5A5A5A5u)
<< "the write-back reached guest memory the tag never named";
EXPECT_EQ(ReadTag(kProbeLine) & (kFlagValid | kFlagDirty), 0u);
}
// Deliberately unpinned: where an eviction goes when the tag names one of the
// emulator's main-RAM mirrors at 0x20000000 or 0x30000000. Those are our
// physical map's mirrors, not the console's -- a console has no RAM at those
// physical addresses, and an eviction aimed there reached nothing on the
// SCPH-30001. Any assertion here would freeze an emulator-specific answer to a
// question no game asks, so leave it undefined.
// ---------------------------------------------------------------------------
// Tripwires. DxstgDirtyStaysInsideGuestMemory has graduated and holds; the rest
// fail today and turn green when the missing model appears.