Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

508 lines
32 KiB
Diff

--- before/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c
+++ after/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c
@@ -95,6 +95,7 @@
protected ref ERR_ObserverHeadVisibility m_ViewHead = new ERR_ObserverHeadVisibility();
protected float m_OrbitFov, m_OrbitNear;
protected ref ERR_ObserverScopeDisplay m_ScopeDisplay = new ERR_ObserverScopeDisplay();
+ protected ref ERR_ObserverPIPDisplay m_PIPDisplay = new ERR_ObserverPIPDisplay();
protected bool m_InputContextActive;
bool InputContextActive() { return m_InputContextActive; }
bool IsPlayerView() { return m_PlayerView; }
@@ -122,11 +123,11 @@
m_OrbitNear = GetCameraEntity().GetNearPlane();
return true;
}
- override void EOnCameraExit() { m_ScopeDisplay.Close(); m_ViewHead.Restore(); }
+ override void EOnCameraExit() { m_PIPDisplay.Close(); m_ScopeDisplay.Close(); m_ViewHead.Restore(); }
override void EOnCameraFrame(SCR_ManualCameraParam param)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
- if (!controller || GetGame().GetCameraManager().CurrentCamera() != GetCameraEntity()) { m_ScopeDisplay.Close(); m_ViewHead.Restore(); return; }
+ if (!controller || GetGame().GetCameraManager().CurrentCamera() != GetCameraEntity()) { m_PIPDisplay.Close(); m_ScopeDisplay.Close(); m_ViewHead.Restore(); return; }
m_InputContextActive = GetInputManager().IsContextActive("ERR_ObserverContext");
Input(param.isManualInputEnabled, GetInputManager().GetActionValue("ERR_ObserverPrevious") > 0.5, GetInputManager().GetActionValue("ERR_ObserverNext") > 0.5, GetInputManager().GetActionValue("ERR_ObserverView") > 0.5);
IEntity target = controller.ERR_ObservedEntity();
@@ -134,7 +135,6 @@
if (m_PlayerView && controller.ERR_ReadPlayerView(target, param.timeSlice, param.transform, fov, nearPlane))
{
param.fov = fov; GetCameraEntity().SetNearPlane(nearPlane);
- m_ViewHead.Update(target, param.transform[3]);
RplId scopeWeapon; vector scopeData;
controller.ERR_ReadScope(scopeWeapon, scopeData);
SCR_2DOpticsComponent optic = ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData);
@@ -144,13 +144,31 @@
RplComponent weaponRpl = RplComponent.Cast(Replication.FindItem(scopeWeapon));
if (weaponRpl) weapon = weaponRpl.GetEntity();
}
- if (!param.isManualInputEnabled || !optic || !weapon || !m_ScopeDisplay.RenderScope(optic, weapon, fov, scopeData)) m_ScopeDisplay.Close();
+ vector eye[4], geometry;
+ float eyeFov, eyeNear, pipFov, vignette;
+ // Read both timelines every camera frame, including the 2D fallback, so
+ // enabling PiP cannot resume an eye camera from a different time.
+ bool haveEye = controller.ERR_ReadPlayerEye(target, param.timeSlice, eye, eyeFov, eyeNear, pipFov, geometry, vignette);
+ bool pip = haveEye && param.isManualInputEnabled && optic && weapon;
+ if (pip) pip = m_PIPDisplay.Render(optic, param.transform, eye, fov, pipFov, scopeData, geometry, vignette);
+ if (pip)
+ {
+ m_ScopeDisplay.Close();
+ Math3D.MatrixCopy(eye, param.transform);
+ param.fov = eyeFov; GetCameraEntity().SetNearPlane(eyeNear);
+ }
+ else
+ {
+ m_PIPDisplay.Close();
+ if (!param.isManualInputEnabled || !optic || !weapon || !m_ScopeDisplay.RenderScope(optic, weapon, fov, scopeData)) m_ScopeDisplay.Close();
+ }
+ m_ViewHead.Update(target, param.transform[3]);
param.isDirty = true;
return;
}
// No estimated head camera: corpses, streaming gaps and expired samples use
// the stable follow view until an authenticated live camera stream arrives.
- m_ScopeDisplay.Close();
+ m_PIPDisplay.Close(); m_ScopeDisplay.Close();
m_ViewHead.Restore();
param.fov = m_OrbitFov; GetCameraEntity().SetNearPlane(m_OrbitNear);
vector aim = controller.ERR_ObserverAnchor() + "0 1.5 0";
--- before/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c
+++ after/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c
@@ -2,6 +2,18 @@
// spectators of that body. No client-supplied position affects gameplay.
class ERR_ObserverViewRules
{
+ // One unreliable packet contains both cameras, so loss/reordering cannot
+ // pair a new eye pose with an old aiming pose. Four vectors for ordinary/2D,
+ // nine for PiP: scope pose/lens, eye pose/lens, native lens geometry.
+ static bool Packet(array<vector> packet)
+ {
+ if (!packet || (packet.Count() != 4 && packet.Count() != 9)) return false;
+ if (!Sample(packet[0], packet[1], packet[2], packet[3][0], packet[3][1])) return false;
+ if (packet.Count() == 4) return packet[3][2] == 0;
+ if (!(packet[3][2] >= 0.01 && packet[3][2] <= 150)) return false;
+ if (!Sample(packet[4], packet[5], packet[6], packet[7][0], packet[7][1])) return false;
+ return packet[7][2] >= 0 && packet[7][2] <= 100 && Math.AbsFloat(packet[8][0]) <= 90 && Math.AbsFloat(packet[8][1]) <= 90 && packet[8][2] >= 0.001 && packet[8][2] <= 100;
+ }
// The body and camera arrive through independently timed replication streams.
// Keep the sampled world axes and world-axis eye offset together. Applying
// the proxy body's rotation to delayed body-local aim adds its yaw error to
@@ -32,6 +44,8 @@
int Stamp;
vector Position, Forward, Up;
float Fov, Near;
+ float PipFov, Vignette;
+ vector Geometry;
}
class ERR_ObserverViewBuffer
{
@@ -41,15 +55,23 @@
protected vector m_ScopeData;
protected float m_Received = -1;
protected float m_Playback, m_ReadAt;
+ protected float m_PipFov, m_Vignette;
+ protected vector m_Geometry;
protected ref array<ref ERR_ObserverViewSample> m_Samples = {};
void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
+ bool Accepts(RplId target, int stamp) { return target != m_Target || stamp > m_Stamp; }
+ float Playback() { return m_Playback; }
+ void Align(float playback, float now) { m_Playback = playback; m_ReadAt = now; }
+ void PIP(out float fov, out vector geometry, out float vignette)
+ { fov = m_PipFov; geometry = m_Geometry; vignette = m_Vignette; }
void Clear()
{
m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1;
m_ScopeWeapon = RplId.Invalid(); m_ScopeData = vector.Zero;
m_Samples.Clear();
- }
- void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
+ m_PipFov = 0; m_Vignette = 0; m_Geometry = vector.Zero;
+ }
+ void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData, float pipFov = 0, vector geometry = "0 0 0", float vignette = 0)
{
if (!ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
if (!ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
@@ -62,6 +84,7 @@
ERR_ObserverViewSample sample = new ERR_ObserverViewSample();
sample.Stamp = stamp; sample.Position = position; sample.Forward = forward; sample.Up = up;
sample.Fov = fov; sample.Near = nearPlane;
+ sample.PipFov = pipFov; sample.Geometry = geometry; sample.Vignette = vignette;
m_Samples.Insert(sample);
// Bounded even when first-person view is not being rendered.
while (m_Samples.Count() > 12) m_Samples.Remove(0);
@@ -94,12 +117,17 @@
Math3D.DirectionAndUpMatrix(forward, up, local);
local[3] = vector.Lerp(start.Position, end.Position, blend);
fov = Math.Lerp(start.Fov, end.Fov, blend); nearPlane = end.Near;
+ m_PipFov = Math.Lerp(start.PipFov, end.PipFov, blend);
+ m_Geometry = vector.Lerp(start.Geometry, end.Geometry, blend);
+ m_Vignette = Math.Lerp(start.Vignette, end.Vignette, blend);
return true;
}
}
modded class SCR_PlayerController
{
protected ref ERR_ObserverViewBuffer m_ERRViewBuffer = new ERR_ObserverViewBuffer();
+ protected ref ERR_ObserverViewBuffer m_ERREyeBuffer = new ERR_ObserverViewBuffer();
+ protected bool m_ERRHasPIP;
protected ref array<SCR_PlayerController> m_ERRViewWatchers = {};
protected float m_ERRViewServerRequestAt = -1000;
protected float m_ERRViewRequestAt = -1000, m_ERRViewLeaseUntil = -1;
@@ -113,6 +141,19 @@
return mode && mode.Active() && now <= m_ERRViewLeaseUntil && target && target == m_ERRServerObserved && ERR_Actors.CanFight(target) && mode.Players().CurrentActor(target) && SCR_FactionManager.SGetFaction(target) == mode.PlayerFaction() && GetGame().GetPlayerManager().GetPlayerController(GetPlayerId()) == this && ERR_ObserverRules.View(m_ERRObserverReason, true, ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
}
void ERR_ReadScope(out RplId weapon, out vector data) { m_ERRViewBuffer.Scope(weapon, data); }
+ bool ERR_ReadPlayerEye(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane, out float pipFov, out vector geometry, out float vignette)
+ {
+ if (!m_ERRHasPIP || this != GetGame().GetPlayerController() || !target || !ERR_Actors.CanFight(target)) return false;
+ vector pose[4], world[4];
+ float now = GetGame().GetWorld().GetWorldTime();
+ // Use the exact aiming playhead even after a streamed optic temporarily
+ // fails resolution and this eye buffer is not read for several frames.
+ m_ERREyeBuffer.Align(m_ERRViewBuffer.Playback(), now);
+ if (!m_ERREyeBuffer.Read(m_ERRObservedId, now, seconds, pose, fov, nearPlane)) return false;
+ target.GetWorldTransform(world); ERR_ObserverViewRules.Decode(world, pose, transform);
+ m_ERRViewBuffer.PIP(pipFov, geometry, vignette);
+ return true;
+ }
bool ERR_ReadPlayerView(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane)
{
if (this != GetGame().GetPlayerController() || !target || !ERR_Actors.CanFight(target)) return false;
@@ -171,20 +212,37 @@
PlayerCamera camera = GetPlayerCamera();
CameraManager manager = GetGame().GetCameraManager();
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor) || !camera || !manager || manager.CurrentCamera() != camera) return;
- vector world[4], view[4], pose[4];
+ vector world[4], view[4], pose[4], eye[4];
actor.GetWorldTransform(world);
camera.GetWorldCameraTransform(view);
float fov = camera.GetVerticalFOV(), nearPlane = camera.GetNearPlane();
+ float eyeFov = fov;
+ ERR_ObserverViewRules.Encode(world, view, eye);
RplId scopeWeapon; vector scopeData;
ERR_ObserverScope.Sample(actor, view, fov, scopeWeapon, scopeData);
ERR_ObserverViewRules.Encode(world, view, pose);
if (!ERR_ObserverViewRules.Sample(pose[3], pose[2], pose[1], fov, nearPlane)) return;
- Rpc(ERR_SubmitPlayerView, ++m_ERRViewSequence, pose[3], pose[2], pose[1], fov, nearPlane, scopeWeapon, scopeData);
+ array<vector> packet = {pose[3], pose[2], pose[1], Vector(fov, nearPlane, 0)};
+ SCR_2DPIPSightsComponent pip = SCR_2DPIPSightsComponent.Cast(ERR_ObserverScope.Resolve(actor, scopeWeapon, scopeData));
+ if (pip && pip.IsPIPEnabled() && pip.GetPIPCamera())
+ {
+ packet[3] = Vector(fov, nearPlane, pip.GetPIPCamera().GetVerticalFOV());
+ packet.Insert(eye[3]); packet.Insert(eye[2]); packet.Insert(eye[1]);
+ packet.Insert(Vector(eyeFov, nearPlane, pip.ERR_ObserverPIPVignette(actor, camera))); packet.Insert(pip.ERR_ObserverPIPGeometry());
+ if (!ERR_ObserverViewRules.Packet(packet))
+ {
+ // An unusual addon lens must not suppress an otherwise valid 2D view.
+ packet.Resize(4); packet[3] = Vector(fov, nearPlane, 0);
+ }
+ }
+ if (!ERR_ObserverViewRules.Packet(packet)) return;
+ Rpc(ERR_SubmitPlayerView, ++m_ERRViewSequence, packet, scopeWeapon, scopeData);
}
[RplRpc(RplChannel.Unreliable, RplRcver.Server)]
- protected void ERR_SubmitPlayerView(int sequence, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
- {
- if (!Replication.IsServer() || !ERR_ObserverScope.Valid(scopeWeapon, scopeData) || !ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
+ protected void ERR_SubmitPlayerView(int sequence, array<vector> packet, RplId scopeWeapon, vector scopeData)
+ {
+ if (!Replication.IsServer() || !ERR_ObserverScope.Valid(scopeWeapon, scopeData) || !ERR_ObserverViewRules.Packet(packet)) return;
+ if (packet.Count() == 9 && scopeWeapon == RplId.Invalid()) return;
ERR_GameMode mode = ERR_GameMode.Get();
IEntity actor = GetControlledEntity();
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor)) return;
@@ -200,23 +258,38 @@
{
SCR_PlayerController watcher = m_ERRViewWatchers[i];
if (!watcher || !watcher.ERR_ViewRecipient(actor, now)) { m_ERRViewWatchers.Remove(i); continue; }
- watcher.ERR_DeliverPlayerView(component.Id(), now, position, forward, up, fov, nearPlane, scopeWeapon, scopeData);
- }
- }
- void ERR_DeliverPlayerView(RplId target, int stamp, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
- {
- Rpc(ERR_ReceivePlayerViewPacket, target, stamp, position, forward, up, Vector(fov, nearPlane, 0), scopeWeapon, scopeData);
+ watcher.ERR_DeliverPlayerView(component.Id(), now, packet, scopeWeapon, scopeData);
+ }
+ }
+ void ERR_DeliverPlayerView(RplId target, int stamp, array<vector> packet, RplId scopeWeapon, vector scopeData)
+ {
+ Rpc(ERR_ReceivePlayerViewPacket, target, stamp, packet, scopeWeapon, scopeData);
}
[RplRpc(RplChannel.Unreliable, RplRcver.Owner)]
- protected void ERR_ReceivePlayerViewPacket(RplId target, int stamp, vector position, vector forward, vector up, vector lens, RplId scopeWeapon, vector scopeData)
- {
- ERR_ReceivePlayerView(target, stamp, position, forward, up, lens[0], lens[1], scopeWeapon, scopeData);
- }
- protected void ERR_ReceivePlayerView(RplId target, int stamp, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
+ protected void ERR_ReceivePlayerViewPacket(RplId target, int stamp, array<vector> packet, RplId scopeWeapon, vector scopeData)
+ {
+ ERR_ReceivePlayerView(target, stamp, packet, scopeWeapon, scopeData);
+ }
+ protected void ERR_ReceivePlayerView(RplId target, int stamp, array<vector> packet, RplId scopeWeapon, vector scopeData)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (this != GetGame().GetPlayerController() || target == RplId.Invalid() || target != m_ERRObservedId || !mode || !ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()))) return;
- m_ERRViewBuffer.Push(target, stamp, GetGame().GetWorld().GetWorldTime(), position, forward, up, fov, nearPlane, scopeWeapon, scopeData);
+ if (!ERR_ObserverViewRules.Packet(packet) || !ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
+ if (packet.Count() == 9 && scopeWeapon == RplId.Invalid()) return;
+ // Reject before updating either stream or PiP metadata.
+ if (!m_ERRViewBuffer.Accepts(target, stamp)) return;
+ float now = GetGame().GetWorld().GetWorldTime();
+ bool pip = packet.Count() == 9;
+ if (pip != m_ERRHasPIP) { m_ERRViewBuffer.Clear(); m_ERREyeBuffer.Clear(); }
+ vector geometry; float vignette;
+ if (pip) { geometry = packet[8]; vignette = packet[7][2]; }
+ m_ERRViewBuffer.Push(target, stamp, now, packet[0], packet[1], packet[2], packet[3][0], packet[3][1], scopeWeapon, scopeData, packet[3][2], geometry, vignette);
+ m_ERRHasPIP = pip;
+ if (pip)
+ {
+ m_ERREyeBuffer.Push(target, stamp, now, packet[4], packet[5], packet[6], packet[7][0], packet[7][1], scopeWeapon, scopeData);
+ }
+ else m_ERREyeBuffer.Clear();
}
}
modded class ArmaReforgerScripted
--- before/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c
+++ after/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c
@@ -1,5 +1,5 @@
-// Presentation only. Scoped spectators use a private full-screen optic display;
-// never activate the remote sight (which registers weapon input and changes HUD).
+// Presentation only. PiP spectators use the lens renderer; other scopes use a
+// private full-screen display. Never activate remote ADS input or its HUD.
class ERR_ObserverScope
{
static bool Valid(RplId weapon, vector data)
@@ -132,3 +132,123 @@
}
void ~ERR_ObserverScopeDisplay() { Close(); }
}
+
+// Use only the native PiP rendering lifecycle, never OnSightADSActivated or
+// HandleSightActivation (those bind weapon input and change the player's HUD).
+modded class SCR_2DPIPSightsComponent
+{
+ protected bool m_ERRObserverPIPOwned;
+ vector ERR_ObserverPIPGeometry() { return Vector(m_fParallaxX, m_fParallaxY, m_fEdgeScale); }
+ float ERR_ObserverPIPVignette(IEntity actor, CameraBase camera)
+ {
+ SCR_2DPIPSightsComponentClass settings = SCR_2DPIPSightsComponentClass.Cast(GetComponentData(GetOwner()));
+ ChimeraCharacter character = ChimeraCharacter.Cast(actor);
+ if (!settings || !camera || !character || !character.GetCharacterController()) return 0;
+ BaseWeaponManagerComponent weapons = character.GetCharacterController().GetWeaponManagerComponent();
+ vector eye[4], sight[4]; float ignoredFov;
+ if (!weapons || !weapons.GetCurrentSightsCameraTransform(sight, ignoredFov)) return settings.GetBasicParallax();
+ camera.GetLocalTransform(eye);
+ float alignment = vector.Dot(eye[2], sight[2]);
+ if (Math.AbsFloat(alignment) < 0.001) return settings.GetMaxParallax();
+ // The same eye-to-reticle-plane distance used by native UpdateScopeMaterial.
+ float distance = Math.AbsFloat(vector.Dot(sight[3] - eye[3], sight[2]) / alignment) - settings.GetCenterDistance();
+ float power = settings.GetDistanceMoveNear();
+ if (distance > 0) power = settings.GetDistanceMoveFar();
+ float falloff = 1 - 1 / Math.Log2(power * Math.AbsFloat(distance) + 2);
+ return Math.Clamp(falloff * 100, settings.GetBasicParallax(), settings.GetMaxParallax());
+ }
+ bool ERR_ObserverPIPBegin()
+ {
+ if (m_ERRObserverPIPOwned) return m_bPIPIsEnabled && m_PIPCamera && m_pMaterial;
+ if (SCR_Global.IsScope2DEnabled() || IsPIPActive() || m_bPIPIsEnabled || !m_pMaterial || m_fScopeRadius <= 0) return false;
+ if (m_iReticleScaleIndex < 0 || m_iReticleColorIndex < 0 || m_iEdgeMinIndex < 0 || m_iEdgeMaxIndex < 0 || m_iVignettePowerIndex < 0) return false;
+ SCR_2DPIPSightsComponentClass data = SCR_2DPIPSightsComponentClass.Cast(GetComponentData(GetOwner()));
+ // Never reuse the main camera slot or a camera owned by another sight.
+ if (!data || data.GetCameraIndex() <= 0 || data.GetCameraIndex() > 31) return false;
+ m_ERRObserverPIPOwned = true;
+ SetPIPEnabled(true);
+ if (!m_bPIPIsEnabled || !m_PIPCamera || !m_wRenderTargetTextureWidget)
+ { ERR_ObserverPIPEnd(); return false; }
+ return true;
+ }
+ void ERR_ObserverPIPEnd()
+ {
+ if (!m_ERRObserverPIPOwned) return;
+ m_ERRObserverPIPOwned = false;
+ if (m_bPIPIsEnabled) SetPIPEnabled(false);
+ else Destroy(); // also dispose a partially allocated camera/layout
+ }
+ bool ERR_ObserverPIPRender(vector scopeView[4], vector eyeView[4], float scopeFov, float pipFov, vector data, vector geometry, float vignette)
+ {
+ if (!m_ERRObserverPIPOwned || !m_bPIPIsEnabled || !m_PIPCamera || !m_pMaterial || !GetOwner() || GetOwner().IsDeleted()) return false;
+ if (!(pipFov >= 0.01 && pipFov <= 150) || !(scopeFov >= 1 && scopeFov <= 150)) return false;
+ SCR_2DPIPSightsComponentClass settings = SCR_2DPIPSightsComponentClass.Cast(GetComponentData(GetOwner()));
+ if (!settings) return false;
+ float reticlePIPScale = settings.GetReticlePIPScale();
+ if (!(reticlePIPScale > 0)) return false;
+ // Camera pose and lens geometry come from the owner. Never add proxy weapon
+ // animation/parallax a second time to an already sampled aiming direction.
+ m_PIPCamera.SetWorldTransform(scopeView);
+ m_PIPCamera.SetVerticalFOV(pipFov);
+ m_PIPCamera.ApplyProps(settings.GetCameraIndex());
+ UpdateHDR();
+ float angularSize, portion, baseZoom;
+ GetReticleData(angularSize, portion, baseZoom);
+ float reticleFov = scopeFov;
+ if (baseZoom > 0) reticleFov = CalculateZoomFOV(baseZoom);
+ else if (baseZoom < 0) reticleFov = -baseZoom;
+ float reticleScale = 1;
+ if (angularSize > 0) reticleScale = reticleFov * (pipFov / scopeFov) * portion / angularSize;
+ float parallaxX = geometry[0], parallaxY = geometry[1];
+ float objective = m_fObjectiveFov * m_fObjectiveScale;
+ float vignetteScale;
+ if (objective > 0) vignetteScale = settings.GetVignetteParallaxScale() / objective;
+ // Lens shading was sampled on the owner too. Native UpdateScopeMaterial
+ // would consult the spectator's own corpse/weapon manager instead.
+ m_pMaterial.SetParamByIndex(m_iVignetteCenterXIndex, -parallaxX * vignetteScale / pipFov);
+ m_pMaterial.SetParamByIndex(m_iVignetteCenterYIndex, -parallaxY * vignetteScale / pipFov);
+ m_pMaterial.SetParamByIndex(m_iVignettePowerIndex, vignette);
+ m_pMaterial.SetParamByIndex(m_iEdgeCenterXIndex, parallaxX * reticlePIPScale / pipFov);
+ m_pMaterial.SetParamByIndex(m_iEdgeCenterYIndex, parallaxY * reticlePIPScale / pipFov);
+ float edgeSize = objective * geometry[2] / pipFov;
+ m_pMaterial.SetParamByIndex(m_iEdgeMinIndex, 0.5 * reticlePIPScale * edgeSize * edgeSize * settings.GetObjectivePIPEdgeMin());
+ m_pMaterial.SetParamByIndex(m_iEdgeMaxIndex, 0.5 * reticlePIPScale * edgeSize * edgeSize * settings.GetObjectivePIPEdgeMax());
+ m_pMaterial.SetParamByIndex(m_iReticleScaleIndex, reticleScale / reticlePIPScale);
+ m_pMaterial.SetParamByIndex(m_iReticleOffsetXIndex, reticleScale * (parallaxX + GetReticleOffsetX()) / pipFov);
+ m_pMaterial.SetParamByIndex(m_iReticleOffsetYIndex, reticleScale * (parallaxY + data[1]) / pipFov);
+ Color color = settings.GetReticleColor();
+ if (data[2] == 1 && settings.HasIllumination()) color = settings.GetReticleIlluminationColor();
+ float rgba[4] = {color.R(), color.G(), color.B(), color.A()};
+ m_pMaterial.SetParamByIndex(m_iReticleColorIndex, rgba);
+ return true;
+ }
+}
+
+class ERR_ObserverPIPDisplay
+{
+ protected SCR_2DPIPSightsComponent m_Optic;
+ protected SCR_2DPIPSightsComponent m_FailedOptic;
+ protected float m_RetryAt;
+ bool Render(SCR_2DOpticsComponent optic, vector scopeView[4], vector eyeView[4], float scopeFov, float pipFov, vector data, vector geometry, float vignette)
+ {
+ SCR_2DPIPSightsComponent pip = SCR_2DPIPSightsComponent.Cast(optic);
+ if (!pip || SCR_Global.IsScope2DEnabled()) { Close(); return false; }
+ float now = GetGame().GetWorld().GetWorldTime();
+ if (m_Optic != pip)
+ {
+ Close();
+ if (m_FailedOptic == pip && now < m_RetryAt) return false;
+ if (!pip.ERR_ObserverPIPBegin()) { m_FailedOptic = pip; m_RetryAt = now + 2000; return false; }
+ m_Optic = pip; m_FailedOptic = null;
+ }
+ if (m_Optic.ERR_ObserverPIPRender(scopeView, eyeView, scopeFov, pipFov, data, geometry, vignette)) return true;
+ Close(); m_FailedOptic = pip; m_RetryAt = now + 2000;
+ return false;
+ }
+ void Close()
+ {
+ if (m_Optic && m_Optic.GetOwner() && !m_Optic.GetOwner().IsDeleted()) m_Optic.ERR_ObserverPIPEnd();
+ m_Optic = null;
+ }
+ void ~ERR_ObserverPIPDisplay() { Close(); }
+}
--- before/tests/test_observer_view_relay.py
+++ after/tests/test_observer_view_relay.py
@@ -3,7 +3,7 @@
from policy_harness import ROOT,body,run
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
def adapt(s):
- s=s.replace('protected ', '').replace('null','nullptr')
+ s=s.replace('vector geometry; float vignette;', 'vector geometry; float vignette=0;').replace('protected ', '').replace('null','nullptr').replace('array<vector> packet', 'Array<vector>* packet').replace('packet.Count()', 'packet->Count()').replace('packet[', '(*packet)[')
for cls in ['ERR_GameMode','ERR_PlayerLife','SCR_PlayerController','RplComponent']:
s=re.sub(r'\b'+cls+r' (\w+)\b',cls+r'* \1',s)
for obj in ['mode','life','watcher','source','component']:s=s.replace(obj+'.',obj+'->')
@@ -33,22 +33,26 @@
namespace SCR_FactionManager{int SGetFaction(IEntity a){return a?a->faction:0;}}
namespace ERR_ObserverReason{int NONE=0,UNCONSCIOUS=4;}
struct ERR_ObserverRules {'''+body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c','static bool View(').replace('ERR_ObserverReason.','ERR_ObserverReason::')+r'''};
-struct ERR_ObserverViewRules {'''+adapt(body(p,'static bool Sample('))+r'''};
+template<class T>struct Array:std::vector<T>{int Count(){return this->size();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}void Insert(T v){this->push_back(v);}void Remove(int i){this->erase(this->begin()+i);}};
+struct ERR_ObserverViewRules {'''+adapt(body(p,'static bool Sample('))+adapt(body(p,'static bool Packet('))+r'''};
struct ERR_ObserverScope {'''+adapt(body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c','static bool Valid('))+r'''};
namespace Replication{bool server=true;bool IsServer(){return server;}}
struct SCR_PlayerController;
struct Game {float now=1000;SCR_PlayerController* local=nullptr;std::map<int,SCR_PlayerController*> controllers;
Game* GetWorld(){return this;}Game* GetPlayerManager(){return this;}float GetWorldTime(){return now;}SCR_PlayerController* GetPlayerController(){return local;}SCR_PlayerController* GetPlayerController(int id){return controllers[id];}} game;
Game* GetGame(){return &game;}
-template<class T>struct Array:std::vector<T>{int Count(){return this->size();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}void Insert(T v){this->push_back(v);}void Remove(int i){this->erase(this->begin()+i);}};
-struct Buffer{int pushes=0;void Push(RplId,int,float,vector,vector,vector,float,float,RplId,vector){pushes++;}};
+
+struct Buffer{float pipFov=0;int pushes=0,stamp=-1;RplId target;bool Accepts(RplId t,int s){return t!=target||s>stamp;}void Clear(){stamp=-1;target=-1;}void Push(RplId t,int s,float,vector,vector,vector,float,float,RplId,vector,float pf=0,vector g={},float v=0){pushes++;stamp=s;target=t;pipFov=pf;}};
struct SCR_PlayerController {
int id=0;IEntity controlled=nullptr,m_ERRServerObserved=nullptr,m_ERRViewSourceBody=nullptr;RplId m_ERRObservedId;
int m_ERRObserverReason=1,m_ERRViewInputSequence=0,leases=0,deliveries=0;float m_ERRViewLeaseUntil=-1,m_ERRViewServerRequestAt=-1000,m_ERRViewLeaseSent=-1000,m_ERRViewPublishUntil=-1,m_ERRViewLastPacket=-1000;
- Array<SCR_PlayerController*> m_ERRViewWatchers;Buffer m_ERRViewBuffer;
+ Array<SCR_PlayerController*> m_ERRViewWatchers;Buffer m_ERRViewBuffer,m_ERREyeBuffer;bool m_ERRHasPIP=false;
int GetPlayerId(){return id;}IEntity GetControlledEntity(){return controlled;}static SCR_PlayerController* Cast(SCR_PlayerController* p){return p;}
void SendLease(){leases++;}
- void ERR_DeliverPlayerView(RplId,int,vector,vector,vector,float,float,RplId,vector){deliveries++;}
+ void ERR_DeliverPlayerView(RplId,int,Array<vector>*,RplId,vector){deliveries++;}
+ // Legacy scenario call shape only constructs the new on-wire packet.
+ void ERR_SubmitPlayerView(int seq,vector p,vector f,vector u,float fov,float near,RplId weapon,vector data){Array<vector> a;a.Insert(p);a.Insert(f);a.Insert(u);a.Insert({fov,near,0});ERR_SubmitPlayerView(seq,&a,weapon,data);}
+ void ERR_ReceivePlayerView(RplId target,int stamp,vector p,vector f,vector u,float fov,float near,RplId weapon,vector data){Array<vector> a;a.Insert(p);a.Insert(f);a.Insert(u);a.Insert({fov,near,0});ERR_ReceivePlayerView(target,stamp,&a,weapon,data);}
'''+methods+r'''
};
int main(){
@@ -79,6 +83,14 @@
watcher.ERR_ReceivePlayerView({22},2,position,forward,up,60,.0125, -1, {});assert(watcher.m_ERRViewBuffer.pushes==1);
game.local=&stranger;watcher.ERR_ReceivePlayerView({11},3,position,forward,up,60,.0125, -1, {});assert(watcher.m_ERRViewBuffer.pushes==1);
game.local=&watcher;mode.active=false;watcher.ERR_ReceivePlayerView({11},4,position,forward,up,60,.0125, -1, {});assert(watcher.m_ERRViewBuffer.pushes==1);
+ mode.active=true;game.local=&watcher;
+ Array<vector> dual;for(vector v:{position,forward,up,vector{12,.02,6},position,forward,up,vector{60,.02,0},vector{0,0,1}})dual.Insert(v);
+ watcher.ERR_ReceivePlayerView(11,20,&dual,22,{1,0,0});assert(watcher.m_ERRHasPIP&&watcher.m_ERREyeBuffer.stamp==20&&watcher.m_ERRViewBuffer.stamp==20&&watcher.m_ERRViewBuffer.pipFov==6);
+ dual[3].z=4;watcher.ERR_ReceivePlayerView(11,19,&dual,22,{1,0,0});assert(watcher.m_ERRViewBuffer.pipFov==6&&watcher.m_ERREyeBuffer.stamp==20);
+ dual[4]={999,0,0};watcher.ERR_ReceivePlayerView(11,21,&dual,22,{1,0,0});assert(watcher.m_ERRViewBuffer.stamp==20);dual[4]=position;
+ watcher.ERR_ReceivePlayerView(11,21,&dual,-1,{});assert(watcher.m_ERRViewBuffer.stamp==20); // PiP requires a scoped weapon
+ watcher.ERR_ReceivePlayerView(11,22,position,forward,up,60,.02,-1,{});assert(!watcher.m_ERRHasPIP&&watcher.m_ERREyeBuffer.stamp==-1&&watcher.m_ERRViewBuffer.stamp==22);
+ dual.pop_back();assert(!ERR_ObserverViewRules::Packet(&dual));assert(!ERR_ObserverViewRules::Packet(nullptr));
mode.active=true;game.local=&source;source.ERR_EnableViewPublisher();assert(source.m_ERRViewPublishUntil==game.now+2000);
}
''')
--- before/tests/test_observer_view_buffer.py
+++ after/tests/test_observer_view_buffer.py
@@ -3,13 +3,14 @@
from policy_harness import ROOT,body,run
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
def adapt(s):
+ s=s.replace('vector geometry = "0 0 0"', 'vector geometry = {}')
for name in ['ERR_ObserverScope','ERR_ObserverViewRules','Math','Math3D','vector','RplId']:s=s.replace(name+'.',name+'::')
s=s.replace('out vector','vector').replace('out float ','float& ')
s=re.sub(r'ERR_ObserverViewSample (\w+)', r'ERR_ObserverViewSample* \1',s)
s=re.sub(r'(sample|start|end)\.', r'\1->',s)
return re.sub(r'(m_Samples\[\d+\])\.',r'\1->',s)
rules='\n'.join(adapt(body(p,k)) for k in ['static bool Sample(', 'static bool Fresh('])
-methods='\n'.join(adapt(body(p,k)) for k in ['void Clear(', 'void Push(', 'bool Read('])
+methods='\n'.join(adapt(body(p,k)) for k in ['void Clear(', 'void Push(', 'bool Read(', 'float Playback(', 'void Align('])
baseline=ROOT/'docs/evidence/observer-smoothing-2026-10-09/baseline/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
old_methods='\n'.join(adapt(body(baseline,k)) for k in ['void Clear(', 'void Push(', 'bool Read('])
run(r'''
@@ -30,9 +31,9 @@
struct RplId {int value=-1;RplId(int v=-1):value(v){}static RplId Invalid(){return{};}bool operator==(RplId a){return value==a.value;}bool operator!=(RplId a){return value!=a.value;}};
struct ERR_ObserverViewRules {'''+rules+r'''};
struct ERR_ObserverScope {'''+adapt(body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c','static bool Valid('))+r'''};
-struct ERR_ObserverViewSample {int Stamp;vector Position,Forward,Up;float Fov,Near;};
+struct ERR_ObserverViewSample {int Stamp;vector Position,Forward,Up;float Fov,Near,PipFov=0,Vignette=0;vector Geometry;};
template<class T>struct Array:std::vector<T>{int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Insert(T v){this->push_back(v);}void Remove(int i){this->erase(this->begin()+i);}};
-struct Buffer {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1,m_Playback=0,m_ReadAt=0;Array<ERR_ObserverViewSample*> m_Samples;
+struct Buffer {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1,m_Playback=0,m_ReadAt=0,m_PipFov=0,m_Vignette=0;vector m_Geometry;Array<ERR_ObserverViewSample*> m_Samples;
'''+methods+r'''};
struct Baseline {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1;vector m_Position,m_Forward,m_Up,m_StartPosition,m_StartForward,m_StartUp;float m_Fov=0,m_Near=0,m_StartFov=0,m_Blend=1;
'''+old_methods+r'''};
@@ -99,6 +100,22 @@
std::cout<<"frame_ms="<<frame<<" buffered_step_variation="<<newVariation<<" baseline="<<oldVariation<<"\n";
assert(newVariation<oldVariation*.25f);
}
+ // PiP zoom/parallax/shading must use the same delayed time as the camera.
+ Buffer paired;
+ paired.Push(1,100,100,pos,forward,up,12,.02,22,{1,0,0},6,{0,0,1},.8);
+ paired.Push(1,150,150,pos,forward,up,6,.02,22,{1,0,0},3,{2,4,2},1.2);
+ assert(paired.Read(1,225,.016,matrix,fov,nearPlane));
+ assert(close(fov,9)&&close(paired.m_PipFov,4.5)&&close(paired.m_Geometry.x,1)&&close(paired.m_Vignette,1));
+ // A temporarily unresolved optic may skip eye reads. Rejoining uses the
+ // aiming playhead exactly, rather than an independently restarted delay.
+ Buffer aim,eye;
+ for(int i=0;i<20;i++){
+ aim.Push(1,i*50,i*50,{i*.1f,1.6,0},forward,up,12,.02,22,{1,0,0});
+ eye.Push(1,i*50,i*50,{i*.1f,1.6,0},forward,up,60,.02,22,{1,0,0});
+ assert(aim.Read(1,i*50,.05,matrix,fov,nearPlane));
+ }
+ assert(aim.Read(1,975,.025,matrix,fov,nearPlane));float aimX=matrix[3].x;
+ eye.Align(aim.Playback(),975);assert(eye.Read(1,975,.025,matrix,fov,nearPlane));assert(close(aimX,matrix[3].x));
// Holding an unrendered stream stays bounded; opening it resumes near live.
Buffer parked;
for(int i=0;i<1000;i++)parked.Push(1,i*50,i*50,pos,forward,up,60,.02,-1,{});