Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ Nothing is replaced, the mod's files simply sit next to the game's own.

**What you need**

- F.E.A.R. **1.08**, legally installed. Only the Steam Ultimate Shooter
Edition is confirmed in-game; GOG and retail-disc copies of 1.08 install and
launch, but are untested — see [Game editions](#game-editions). Versions
- F.E.A.R. **1.08**, legally installed. The Steam Ultimate Shooter Edition and
the March 2025 GOG Preservation build are confirmed in-game; retail-disc and
other 1.08 revisions remain untested — see [Game editions](#game-editions). Versions
below 1.08 are rejected, because the Public Tools modules do not match them.
- The public download needs the official **Public Tools 1.08** installed
locally — bundled with the Steam Ultimate Shooter Edition under `extras\`, or
Expand Down Expand Up @@ -178,16 +178,24 @@ on disk. What differs between editions is how the game starts:
|---|---|---|
| Steam (Ultimate Shooter Edition 1.08) | `steam.exe -applaunch 21090` | Confirmed in-game |
| Steam 1.08 + HDTextures4FEAR/XP v2.0.2 | `steam.exe -applaunch 21090` | Recognized; exact patched EXE hash confirmed |
| GOG (1.08) | `FEAR.exe` directly, same arguments | Should work, untested |
| GOG Preservation Program (1.08, 2025-03-20) | `FEAR.exe` directly, same arguments | Confirmed in-game; exact EXE/HID signatures and stable sustained play verified |
| Retail disc, patched to 1.08 | `FEAR.exe` directly, same arguments | Should work, untested |

Steam CEG initially exposes encrypted bytes at the two HID patch regions, so
the earliest `dinput8.dll` check cannot match. The GameClient loader repeats
the same guarded check after CEG has unpacked the image but before DirectInput
device initialization; the `DirectInput8Create` proxy performs one final
guarded verification. A successful Steam run can therefore report
`fear_hid_fix result=already_applied`, while GOG normally reports `applied`.

The mod picks the launch mode itself: a copy under `steamapps\common`
goes through Steam, anything else is started directly.

An unknown `FEAR.exe` build is no longer an error. The mod records its
SHA-256 with a warning and continues; on a byte mismatch the HID patch remains
off rather than touching unknown code. If you run a GOG or disc copy, that hash
plus the `fear_hid_fix` result from the log is what is needed to confirm it.
off rather than touching unknown code. If you run another GOG revision or a
disc copy, that hash plus the `fear_hid_fix` result from the log is what is
needed to confirm it.

The HDTextures4FEAR/XP v2.0.2 installer replaces the Steam executable. Its
patched hash and the original Steam hash are both recognized. Installing or
Expand All @@ -199,7 +207,7 @@ require reinstalling the VR mod; unknown executable changes are still rejected.
| Message | Cause and fix |
|---|---|
| `Wrong FEAR.exe version` | Not patched to 1.08, or `-RetailRoot` points at a different installation. |
| `This FEAR.exe build has not been tested` | A 1.08 build other than Steam's (GOG, disc). Installation continues; please report whether it works. |
| `This FEAR.exe build has not been tested` | An unrecognized 1.08 build, such as another GOG revision or a retail-disc executable. Installation continues; please report whether it works. |
| `Public Tools 1.08 not found` | The public download could not find them—install the copy under `extras\`, pass `-PublicToolsGame`, or use a local `-PrivateBundle`. |
| `Package file is missing or was modified` | The package was altered after it was built; unpack it again. |
| Steam creates no `FEAR.exe` after three launch attempts | The launcher now falls back to the verified executable directly while Steam remains running, then still verifies that the matching VR bridge loaded. |
Expand Down
85 changes: 85 additions & 0 deletions docs/ECHOPATCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,91 @@ Ereignis `fear_hid_fix` mit einem der Ergebnisse `applied`,
`already_applied`, `not_fear_108`, `image_too_small`,
`signature_mismatch` oder `protection_failed`.

## Steam CEG und GOG Preservation Program

Die Steam- und die am 20. März 2025 aktualisierte GOG-Fassung sind beide
verifizierte 1.08-Builds:

| Edition | SHA-256 von `FEAR.exe` | `SizeOfImage` | Startmodus |
|---|---|---|---|
| Steam Ultimate Shooter Edition | `D5EBC38A4F12B772C9112A2811C290ADB6C5052D3BC2F817302D38CF55BB2CBE` | `0x001F3000` | Steam App 21090 |
| GOG Preservation Program | `C1678AA4DF37E87C097F45D8EB68A7C379D99AD12D8DA2771CF6235D9493D0B2` | `0x0019D000` | `FEAR.exe` direkt |

Beide melden Dateiversion `1.08.282.0` und PE-Zeitstempel `0x44EF6AE6`.

### Fehlerbild und Ursache

Vor der Korrektur startete die VR-Mod in beiden Editionen normal, aber jedes
Steam-Protokoll und das erste GOG-Protokoll meldeten
`fear_hid_fix result=signature_mismatch`. Die Bildrate war anfangs gut und nahm
anschließend während derselben Sitzung stetig ab. Das entsprach genau dem
ungepatchten HID-Fehler des Basisspiels.

Die beiden relevanten Codeblöcke liegen in beiden Builds an denselben RVAs:

- Der 22 Byte lange redundante HID-Block bei `0x840DD` ist bytegleich mit dem
bereits unterstützten 1.08-Build.
- Der 29 Byte lange Legacy-Input-Block bei `0x84057` verwendet im GOG-Build
auf dem Datenträger und im von CEG entpackten Steam-Prozess dieselben IAT-
Operanden: `FF 15 70 C0 54 00` und `FF 15 3C C4 54 00`.

Beim Laden des frühen Steam-`dinput8.dll`-Proxys sind diese Steam-Codepages
noch CEG-verschlüsselt. Die erste Prüfung muss daher fehlschlagen. Nachdem CEG
das Image entpackt hat, stimmen die Live-Bytes dagegen mit der vollständig
verifizierten gemeinsamen Signatur überein. Die frühere Implementierung
ordnete diese Bytes nur der kleineren GOG-Imagegröße zu und lehnte Steam auch
nach dem Entpacken ab. Eine pauschal gelockerte Signatur wäre unsicher gewesen.

### Abgesicherte Korrektur

Die gemeinsame entpackte 29-Byte-Signatur wird ausschließlich für die beiden
exakten Imagegrößen `0x001F3000` (Steam) und `0x0019D000` (GOG) akzeptiert.
Danach muss weiterhin der gesamte gemeinsame 22-Byte-HID-Block passen.

GOG kann bereits beim Laden des `dinput8.dll`-Proxys gepatcht werden. Bei
Steam wiederholt der GameClient-Loader dieselbe vollständig abgesicherte
Prüfung nach dem CEG-Entpacken, aber vor der DirectInput-Geräteinitialisierung.
Der exportierte `DirectInput8Create`-Proxy führt unmittelbar davor eine letzte
abgesicherte Wiederholungsprüfung aus. Erst wenn beide Blöcke vollständig
verifiziert oder bereits vollständig genoppt sind, werden die noch
ursprünglichen Bytes im Prozessspeicher durch NOPs ersetzt. `FEAR.exe` auf dem
Datenträger bleibt unverändert.

Die exakten Steam- und GOG-SHA-256-Werte sind in der Entwicklungs- und
Release-Erkennung eingetragen. Eine zukünftige Revision wird dadurch nicht
automatisch akzeptiert.

### Verifikation am 8. August 2026

- Vor der Korrektur: Steam und GOG jeweils
`fear_hid_fix result=signature_mismatch`.
- GOG nach der Korrektur: `fear_hid_fix result=applied`.
- Steam nach der Korrektur: `fear_hid_fix result=already_applied`; der
GameClient-Fallback hatte beide Regionen nach dem CEG-Entpacken bereits
erfolgreich gepatcht, bevor der DirectInput-Proxy sie erneut prüfte.
- Read-only-Verifikation im laufenden Steam-Prozess: alle 29 beziehungsweise
22 Bytes der beiden Regionen waren `0x90`.
- Vollständige x86-Testsuite: 24 von 24 Tests bestanden.
- Reale GOG- und Steam-VR-Sitzungen: derselbe Spielstand über längere Zeit
stabil und ohne den zuvor beobachteten fortschreitenden Leistungseinbruch.
- Benutzerprofil und elf vorhandene Spielstände blieben beim Overlay-Update
erhalten.

Der neueste HID-Status lässt sich so prüfen:

```powershell
$log = Get-ChildItem <FEAR>\FEARVR\logs -Recurse -Filter dinput-*.log |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
Get-Content -LiteralPath $log.FullName
```

Für GOG ist normalerweise `result=applied` zu erwarten. Steam meldet
normalerweise `result=already_applied`, weil der Loader nach dem CEG-Entpacken
vor dem DirectInput-Proxy zum Zug kommt. `signature_mismatch` als endgültiges
Protokollergebnis bedeutet, dass eine andere EXE-Revision oder veränderte
Prozessbytes vorliegen; in diesem Fall bleibt der Patch absichtlich aus.

## Was ausdrücklich nicht übernommen wurde

HUD-Skalierung, FOV, SSAA, Controller-/Gyro-Unterstützung, Crash-Handler,
Expand Down
19 changes: 15 additions & 4 deletions src/common/fear_hid_fix.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,26 @@ bool IsAllNops(const std::uint8_t* bytes, std::size_t count) noexcept {
[](const std::uint8_t byte) { return byte == 0x90U; });
}

const std::array<std::uint8_t, 29>& ExpectedLegacyInputHookBytes(
const std::size_t imageSize) noexcept {
if (imageSize == kGogFear108ImageSize ||
imageSize == kSteamFear108ImageSize) {
return kUnpackedLegacyInputHookBytes;
}
return kLegacyInputHookBytes;
}

} // namespace

bool MatchesFear108HidSignatures(const std::uint8_t* image,
const std::size_t imageSize) noexcept {
if (image == nullptr || imageSize < kPatchEnd) {
return false;
}
const auto& legacyBytes = ExpectedLegacyInputHookBytes(imageSize);
return std::memcmp(image + kLegacyInputHookRva,
kLegacyInputHookBytes.data(),
kLegacyInputHookBytes.size()) == 0 &&
legacyBytes.data(),
legacyBytes.size()) == 0 &&
std::memcmp(image + kRedundantHidDeviceRva,
kRedundantHidDeviceBytes.data(),
kRedundantHidDeviceBytes.size()) == 0;
Expand All @@ -45,10 +55,11 @@ bool HasCompatibleFear108HidPatchState(
return false;
}

const auto& legacyBytes = ExpectedLegacyInputHookBytes(imageSize);
const bool legacyOriginal =
std::memcmp(image + kLegacyInputHookRva,
kLegacyInputHookBytes.data(),
kLegacyInputHookBytes.size()) == 0;
legacyBytes.data(),
legacyBytes.size()) == 0;
const bool legacyPatched =
IsAllNops(image + kLegacyInputHookRva,
kLegacyInputHookBytes.size());
Expand Down
12 changes: 12 additions & 0 deletions src/common/fear_hid_fix.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ namespace fearvr {
// were independently verified against our unmodified local 1.08 executable;
// they are checked in full before any process memory is changed.
inline constexpr std::uint32_t kFear108TimeDateStamp = 0x44EF6AE6U;
inline constexpr std::size_t kSteamFear108ImageSize = 0x1F3000U;
// GOG's March 2025 Preservation build keeps the original code RVAs and PE
// timestamp, but has a smaller image. Exact image sizes isolate the shared
// unpacked signature from other 1.08 executables that have not been verified.
inline constexpr std::size_t kGogFear108ImageSize = 0x19D000U;
inline constexpr std::size_t kLegacyInputHookRva = 0x84057U;
inline constexpr std::array<std::uint8_t, 29> kLegacyInputHookBytes{
0x6A, 0x00, 0x6A, 0x00, 0xFF, 0x15, 0x1C, 0x82, 0x70, 0x00,
0x50, 0x68, 0xC0, 0x0C, 0x48, 0x00, 0x6A, 0x0D, 0xFF, 0x15,
0xA4, 0x88, 0x70, 0x00, 0xA3, 0xBC, 0x5B, 0x57, 0x00};

// GOG contains these bytes on disk. Steam exposes the same bytes after CEG
// has unpacked the process image at runtime.
inline constexpr std::array<std::uint8_t, 29> kUnpackedLegacyInputHookBytes{
0x6A, 0x00, 0x6A, 0x00, 0xFF, 0x15, 0x70, 0xC0, 0x54, 0x00,
0x50, 0x68, 0xC0, 0x0C, 0x48, 0x00, 0x6A, 0x0D, 0xFF, 0x15,
0x3C, 0xC4, 0x54, 0x00, 0xA3, 0xBC, 0x5B, 0x57, 0x00};

inline constexpr std::size_t kRedundantHidDeviceRva = 0x840DDU;
inline constexpr std::array<std::uint8_t, 22> kRedundantHidDeviceBytes{
0x8B, 0x3F, 0x8B, 0x0F, 0x6A, 0x01, 0x8D, 0x54, 0x24, 0x0C,
Expand Down
18 changes: 18 additions & 0 deletions src/common/render_pacing.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#pragma once

#include <cstdint>

namespace fearvr {

// FEAR may reach its camera render more than once before OpenXR publishes a
// new request. Only that duplicate render should wait for the next request.
// A request that already differs from the last rendered one is fresh and must
// be consumed immediately.
inline constexpr bool ShouldWaitForNewRenderRequest(
std::uint64_t lastRenderedFrameId,
std::uint64_t currentFrameId) noexcept {
return lastRenderedFrameId != 0 && currentFrameId != 0 &&
currentFrameId == lastRenderedFrameId;
}

} // namespace fearvr
6 changes: 6 additions & 0 deletions src/dinput8_proxy/dinput8_proxy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ extern "C" IDirect3D9* WINAPI FearVrEarlyDirect3DCreate9(
extern "C" HRESULT WINAPI DirectInput8Create(
HINSTANCE instance, DWORD version, REFIID interfaceId,
LPVOID* output, LPUNKNOWN outer) {
// Steam CEG still exposes encrypted code when this proxy loads. The first
// DirectInput8Create call occurs after unpacking but before Retail
// enumerates redundant HID devices, so retry the fully guarded patch.
if (g_hidFixResult == fearvr::FearHidFixResult::SignatureMismatch) {
g_hidFixResult = fearvr::ApplyFear108HidFix();
}
const auto function = reinterpret_cast<DirectInput8CreateFunction>(
SystemExport("DirectInput8Create"));
const HRESULT result = function == nullptr
Expand Down
8 changes: 4 additions & 4 deletions src/gameclient_loader/dllmain.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) {
(void)reserved;
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(instance);
// Fallback for custom launch layouts that cannot place the early
// dinput8 proxy beside FEAR.exe. The proxy normally applies this
// before DirectInput initialization; repeating the guarded operation
// here is safe and prevents later device reinitialization paths.
// Fallback for custom launch layouts and Steam CEG. Steam can load the
// early dinput8 proxy while these FEAR.exe pages are still encrypted;
// this module is loaded after unpacking but before DirectInput device
// initialization. Repeating the guarded operation is safe.
fearvr::ApplyFear108HidFix();
}
return TRUE;
Expand Down
8 changes: 7 additions & 1 deletion src/gameclient_loader/stereo_hook.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
#include "melee_actions.h"
#include "physical_duck.h"
#include "protocol.h"
#include "render_pacing.h"
#include "stereo_math.h"
#include "vertical_camera_height.h"
#include "wrist_hud_visibility.h"
Expand Down Expand Up @@ -7063,8 +7064,13 @@ LTRESULT RenderStereo(ILTRenderer* renderer, HLOCALOBJ camera,
return g_renderCameraWithOverride(
renderer, camera, techniqueOverride);
}
if ((request.flags & FEARVR_RF_FLATSCREEN) != 0) {
return g_renderCameraWithOverride(
renderer, camera, techniqueOverride);
}
if (g_waitForNewRenderRequest != nullptr &&
g_lastStereoRenderRequestId != 0) {
ShouldWaitForNewRenderRequest(
g_lastStereoRenderRequestId, request.frameId)) {
g_stereoStep = "wait_for_fresh_render_request";
FearVrRenderRequest freshRequest{};
constexpr std::uint32_t kMaximumPacingWaitMilliseconds = 20;
Expand Down
46 changes: 44 additions & 2 deletions src/proxy32/bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,10 @@ class Bridge {
}

InterlockedIncrement64(Atomic64(shared_->gameHeartbeat));
if (config_.disableCapture) {
RecordBypassPresentRateLocked();
return;
}
PollPending();
UpdateHostConnection();
EnsureDeviceMetadata(device);
Expand Down Expand Up @@ -1740,6 +1744,40 @@ class Bridge {
}

private:
void RecordBypassPresentRateLocked() noexcept {
const auto now = std::chrono::steady_clock::now();
if (bypassPresentWindowStart_.time_since_epoch().count() == 0) {
bypassPresentWindowStart_ = now;
bypassPresentCount_ = 1;
logger_.Write(
"WARN", "capture_disabled",
"Frame staging and transfer are disabled; Present rate "
"is measured without bridge capture work.");
return;
}

++bypassPresentCount_;
const auto elapsed = std::chrono::duration_cast<
std::chrono::microseconds>(
now - bypassPresentWindowStart_);
constexpr std::int64_t kLogIntervalMicroseconds = 3000000;
if (elapsed.count() < kLogIntervalMicroseconds) {
return;
}
const double framesPerSecond =
static_cast<double>(bypassPresentCount_) * 1000000.0 /
static_cast<double>(elapsed.count());
std::ostringstream message;
message << "presents=" << bypassPresentCount_
<< " window_us=" << elapsed.count()
<< " fps=" << std::fixed << std::setprecision(1)
<< framesPerSecond;
logger_.Write(
"INFO", "capture_bypass_present_rate", message.str());
bypassPresentCount_ = 0;
bypassPresentWindowStart_ = now;
}

void ServiceCaptureDuringPacingWaitLocked() noexcept {
// The current Latest-Frame worker owns CPU/D3D9Ex transfers. Pacing
// only polls its publication query; the older PR queue is disabled.
Expand Down Expand Up @@ -1840,7 +1878,7 @@ class Bridge {
}

void BeginStereoEye(std::uint32_t eye) noexcept {
if (eye >= FEARVR_EYE_COUNT) {
if (eye >= FEARVR_EYE_COUNT || config_.disableCapture) {
return;
}
std::lock_guard<std::mutex> lock(mutex_);
Expand All @@ -1860,7 +1898,8 @@ class Bridge {
}

void CaptureStereoEye(std::uint32_t eye) noexcept {
if (eye >= FEARVR_EYE_COUNT || config_.sessionId == 0) {
if (eye >= FEARVR_EYE_COUNT || config_.sessionId == 0 ||
config_.disableCapture) {
return;
}
std::lock_guard<std::mutex> lock(mutex_);
Expand Down Expand Up @@ -1923,6 +1962,9 @@ class Bridge {
void EndStereoFrame(
std::uint64_t frameId,
const FearVrGameCameraSample* camera) noexcept {
if (config_.disableCapture) {
return;
}
std::lock_guard<std::mutex> lock(mutex_);
RestoreStereoRenderTarget();
if (!stereoAccepting_) {
Expand Down
9 changes: 9 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ if(MSVC)
endif()
add_test(NAME render_scale COMMAND test_render_scale)

add_executable(test_render_pacing test_render_pacing.cpp)
target_link_libraries(
test_render_pacing PRIVATE fearvr-common fearvr-warnings
)
if(MSVC)
target_compile_options(test_render_pacing PRIVATE /UNDEBUG)
endif()
add_test(NAME render_pacing COMMAND test_render_pacing)

if(CMAKE_SIZEOF_VOID_P EQUAL 4 AND TARGET fearvr-proxy)
# Isolierter M2-Producer: Der echte System-Import bleibt d3d9.lib; erst
# nach dem Link wird ausschließlich neben diese Test-EXE der Proxy kopiert.
Expand Down
Loading