Intial Commit

This commit is contained in:
2026-10-27 17:16:21 -04:00
parent 15f0a3fee1
commit 25f8486506
17 changed files with 645 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
# Local build and verification intermediates
work/
*.class
# Private signing keys never belong in the repository
**/ed25519-private.der
**/signing/
@@ -0,0 +1,3 @@
ZBS
SteamID64:76561198079674652
Signature:4462a248868cd920ba2a75dc609af2fc0b73ec170442a3c798bd3f67d173a639e52cb857b8ad009ae728d0d855605bec0d1048ec56205a3b2733497ab0e1fb02
@@ -0,0 +1,25 @@
-- Replace only MTK's tracer UI callback. Its physics, hits, ammunition and
-- VisualProjectilePC lifecycle stay owned by the original mod.
local legacy, replacement
local function install()
local bridge = MTKViewpointProjectiles
if not bridge or not VisualProjectilePC then return end
if not bridge.bind(VisualProjectilePC.removeByID, VisualProjectilePC.list) then
print("[MTKViewpointTurrets] MTK projectile API differs; original drawing retained.")
return
end
if replacement then return end
legacy = bridge.legacyRenderer()
if not legacy then
print("[MTKViewpointTurrets] MTK tracer callback not identified; original drawing retained.")
return
end
replacement = function()
if not MTKViewpointCamera or not MTKViewpointCamera.enabled() then return legacy() end
end
Events.OnPreUIDraw.Remove(legacy)
Events.OnPreUIDraw.Add(replacement)
print("[MTKViewpointTurrets] 3D projectile renderer registered; isometric tracers retained in iso view.")
end
Events.OnGameBoot.Add(install)
Events.OnGameStart.Add(install)
@@ -0,0 +1,121 @@
require "Vehicles/Papa_Chad_Tank_Cannon"
require "Vehicles/Papa_Chad_Tank_Aim"
MTKViewpointTurrets = MTKViewpointTurrets or {}
local M = MTKViewpointTurrets
local seats = { [0] = "SeatFrontLeft", [1] = "SeatGunner", [2] = "SeatFrontRight", [3] = "SeatRearRight" }
local scope = nil
local function enabled()
return MTKViewpointCamera and MTKViewpointCamera.enabled()
end
local function allowed(player, vehicle, weapon)
return player == getPlayer() and not player:isDead()
and MTKViewpointCamera.captured() and getGameSpeed() > 0
and not (UIManager and UIManager.isMouseOverInventory and UIManager.isMouseOverInventory())
and seats[vehicle:getSeat(player)] == weapon.requiredSeat
and player:getCharacterActions():isEmpty()
end
function M.install()
-- These local adapters register their own wrappers at boot/start. Install
-- them first, then wrap the completed chain, regardless of event order.
if KFTFerret and KFTFerret.installClient then KFTFerret.installClient() end
if KLTLAV and KLTLAV.installClient then KLTLAV.installClient() end
if not tankAimSystemPC or not chooseWeaponPC or not ISCoordConversion then return end
if ISCoordConversion.ToWorld ~= M.toWorld then
local previousToWorld = ISCoordConversion.ToWorld
M.toWorld = function(x, y, z)
-- Only override conversions inside this turret update. The Ferret
-- and LAV wrappers otherwise overwrite our camera heading with a
-- floor pick (or the screen-centre isometric fallback).
if scope and scope.x then return scope.x, scope.y end
return previousToWorld(x, y, z)
end
ISCoordConversion.ToWorld = M.toWorld
end
if chooseWeaponPC ~= M.choose then
local previousChoose = chooseWeaponPC
M.choose = function(vehicle)
-- MTK still advances existing projectiles when weapon input is
-- blocked; no simulation callback is removed or duplicated.
if scope and scope.blocked then return nil end
return previousChoose(vehicle)
end
chooseWeaponPC = M.choose
end
if tankAimSystemPC ~= M.aim then
local previousAim = tankAimSystemPC
Events.OnPlayerUpdate.Remove(previousAim)
M.aim = function(player)
local vehicle = player and player:getVehicle()
if not enabled() or not vehicle then return previousAim(player) end
local weapon = chooseWeaponPC(vehicle)
if not weapon then return previousAim(player) end
local oldScope = scope
scope = { blocked = not allowed(player, vehicle, weapon) }
local oldDirection = nil
if not scope.blocked and isMouseButtonDown(1) then
local heading = MTKViewpointCamera.heading()
if type(heading) == "number" and heading == heading and math.abs(heading) < math.huge then
oldDirection = player:getDirectionAngle()
local rad = math.rad(heading)
scope.x = player:getX() + math.cos(rad) * 100
scope.y = player:getY() + math.sin(rad) * 100
player:setDirectionAngle(heading)
else
scope.blocked = true
end
end
-- Restore temporary input even if a dependency raises an error.
local ok, err = pcall(previousAim, player)
scope = oldScope
if oldDirection ~= nil then player:setDirectionAngle(oldDirection) end
if not ok then error(err) end
end
tankAimSystemPC = M.aim
Events.OnPlayerUpdate.Add(M.aim)
print("[MTKViewpointTurrets] MTK camera aiming registered; Ferret/LAV wrappers retained.")
end
if TankCursor and TankCursor.prerender ~= M.cursor then
local previousCursor = TankCursor.prerender
M.cursor = function(self)
if not enabled() then return previousCursor(self) end
-- MTK's two iso-projected textures cannot represent the 3D view.
-- A centred cross marks camera bearing; green means the turret
-- has caught up in yaw, not that ammunition or a hit is guaranteed.
local player = getPlayer()
local vehicle = player and player:getVehicle()
local weapon = vehicle and chooseWeaponPC(vehicle)
if not weapon or not allowed(player, vehicle, weapon) or not isMouseButtonDown(1) then return end
local heading = MTKViewpointCamera.heading()
if not heading then return end
local turret = turretAngles and turretAngles[vehicle:getId()]
local aligned = turret and math.abs(((turret - heading + 540) % 360) - 180) <= 2
local r, g, b = 1, 0.75, 0.25
if aligned then r, g, b = 0.3, 1, 0.4 end
local x = getCore():getScreenWidth() / 2 - self.x
local y = getCore():getScreenHeight() / 2 - self.y
self:drawRect(x - 9, y, 5, 1, 0.9, r, g, b)
self:drawRect(x + 4, y, 5, 1, 0.9, r, g, b)
self:drawRect(x, y - 9, 1, 5, 0.9, r, g, b)
self:drawRect(x, y + 4, 1, 5, 0.9, r, g, b)
end
TankCursor.prerender = M.cursor
end
end
Events.OnGameBoot.Add(M.install)
Events.OnGameStart.Add(M.install)
Events.OnGameStart.Add(function()
if not MTKViewpointCamera then
print("[MTKViewpointTurrets] Camera bridge unavailable. Enable this mod and approve its local JAR in ZombieBuddy, then fully restart the game.")
end
end)
+16
View File
@@ -0,0 +1,16 @@
param([string]$Game = 'D:\SteamLibrary\steamapps\common\ProjectZomboid',
[string]$Viewpoint = 'D:\SteamLibrary\steamapps\workshop\content\108600\3809306528\mods\Viewpoint\42\media\java\client\Viewpoint.jar',
[string]$BuildDirectory = (Join-Path (Get-Location) 'work\MTKViewpointTurrets-build'),
[switch]$Sign)
$ErrorActionPreference = 'Stop'
$buildDir = [System.IO.Path]::GetFullPath($BuildDirectory)
New-Item -ItemType Directory -Force -Path $buildDir | Out-Null
$jarDir = Join-Path $PSScriptRoot '42.20\media\java\client'
New-Item -ItemType Directory -Force -Path $jarDir | Out-Null
$sources = @(Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot 'source') -Recurse -Filter '*.java' | ForEach-Object FullName)
& javac --release 25 -classpath "$Game\projectzomboid.jar;$Game\ZombieBuddy.jar;$Viewpoint" -d $buildDir @sources
if ($LASTEXITCODE -ne 0) { throw 'Java compilation failed.' }
& jar --create --file "$jarDir\MTKViewpointTurrets-0.2.0.jar" -C $buildDir .
if ($LASTEXITCODE -ne 0) { throw 'JAR packaging failed.' }
Write-Host "Built $jarDir\MTKViewpointTurrets-0.2.0.jar"
if ($Sign) { & (Join-Path $PSScriptRoot 'Sign.ps1') }
+58
View File
@@ -1,3 +1,61 @@
# ViewpointTurrets
Ever been reved in a landy?
Military Tool Kit camera aiming and 3D tracers for Project Viewpoint. Supports standard MTK weapons and HRSys Functional Turrets for the Ferret Mk2 and LAV-300 FSV.
Current version: **0.2.0**. Targets **Project Zomboid B42.21**, **Viewpoint 0.1.6.1**, **Military Tool Kit 2.0.0**, and **ZombieBuddy 2.3.0 or later**. The `42.20` folder is the version-folder baseline selected by B42.21; it does not claim compatibility with the B42.20 game executable.
## Mod layout
The repository root is the mod root, ready to be linked directly into `Zomboid/mods`:
```text
ViewpointTurrets/
common/mod.info
42.20/media/lua/client/
42.20/media/java/client/MTKViewpointTurrets-0.2.0.jar
42.20/media/java/client/MTKViewpointTurrets-0.2.0.jar.zbs
source/
tools/SignZBS.java
Build.ps1
Sign.ps1
ZombieBuddy-public-key.txt
```
Use a directory symlink or junction from `Zomboid/mods/MTKViewpointTurrets` to the **repository root**, rather than to either version subfolder. The mod ID remains `MTKViewpointTurrets`, regardless of the checkout folder's name. An existing mod folder must be relocated before creating a link at the same path.
Enable **Military Tool Kit - Viewpoint Turret Aiming** together with MTK, Viewpoint and the relevant vehicle/turret mods. Its metadata orders it after those dependencies. Existing saves have their own mod selection.
## Controls and rendering
From the gunner seat, hold **right mouse** and move the camera to traverse the turret. Hold **right mouse + left mouse** to fire. Releasing right mouse stops commanding a turn. Original ammunition, reloads, damage, traverse speed and angle limits remain supplied by MTK and the vehicle adapters.
The centred cross is amber while traversing and green within two degrees of camera bearing. It indicates horizontal alignment only; camera pitch does not add barrel elevation or guarantee a hit at the crosshair.
Viewpoint uses depth-tested 3D tracers taken from MTK's live projectile positions. The original isometric tracer drawing is suppressed only in Viewpoint and restored in iso mode. Frame snapshots keep mutable Lua tables off the render thread. No second ballistic simulation or damage processing is added. Viewpoint terrain relief and storey height are included in the coordinate conversion.
The native Viewpoint rendering path is supported, including the installed TrueWeathers shader pack. The alternate Minecraft/Iris pipeline is not supported. Multiplayer, controllers and split-screen have not been validated.
## Build and sign
Requires a Java 25 JDK. Run from the repository root:
```powershell
.\Build.ps1
.\Build.ps1 -Sign
```
The default game and Viewpoint JAR locations match this workstation. Override them with `-Game` and `-Viewpoint`. Build intermediates go under the ignored `work/` directory. Output goes directly into `42.20/media/java/client/`.
The distributed JAR is signed for **Riggs0**, SteamID64 **76561198079674652**. Distribute the `.jar.zbs` sidecar with the unchanged JAR. `ZombieBuddy-public-key.txt` contains the public key used for author verification through the Steam profile or ZombieBuddy's known-author list.
The private signing identity stays outside the repository, by default in `%USERPROFILE%\.zombie_buddy\signing\Riggs0`. Reuse that identity; do not generate a replacement when signing another build. `Sign.ps1 -KeyDirectory <path>` supports a different local key location. Never commit the private key.
Any rebuilt JAR needs a fresh signature. `Build.ps1 -Sign` handles both steps. Signing an existing JAR without rebuilding uses `Sign.ps1`. Updated Java code and signatures take effect on the next normal game restart; no launcher or automatic game restart is included.
## Validation
Automated checks exercised the installed MTK code and the game's Kahlua VM for camera traversal, ammunition use, projectile tracking/removal, iso fallback and input gating. A standalone hidden OpenGL test checked tracer visibility, depth occlusion, near-plane clipping and restoration of render state. The two rendering hooks passed ZombieBuddy annotation conversion and offline bytecode weaving against the installed Viewpoint classes. ZombieBuddy's verifier accepted the signature with the supplied public key and rejected a mismatched JAR hash.
Third-person aiming was confirmed in game by the user. The new tracer appearance still needs an in-game check. No game client or save was started for automated verification.
+30
View File
@@ -0,0 +1,30 @@
param(
[string]$KeyDirectory = (Join-Path $env:USERPROFILE '.zombie_buddy\signing\Riggs0'),
[string]$SteamID64 = '76561198079674652',
[switch]$Initialize
)
$ErrorActionPreference='Stop'
$signer=Join-Path $PSScriptRoot 'tools\SignZBS.java'
if($Initialize) {
if(Test-Path -LiteralPath (Join-Path $KeyDirectory 'author.properties')) {throw 'Signing identity already configured.'}
New-Item -ItemType Directory -Force -Path $KeyDirectory | Out-Null
$identity=[Security.Principal.WindowsIdentity]::GetCurrent().User
$acl=[Security.AccessControl.DirectorySecurity]::new()
$acl.SetOwner($identity)
$acl.SetAccessRuleProtection($true,$false)
$inherit=[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
$propagate=[Security.AccessControl.PropagationFlags]::None
$allow=[Security.AccessControl.AccessControlType]::Allow
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($identity,'FullControl',$inherit,$propagate,$allow))
$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($system,'FullControl',$inherit,$propagate,$allow))
Set-Acl -LiteralPath $KeyDirectory -AclObject $acl
& java $signer init $KeyDirectory $SteamID64
if($LASTEXITCODE -ne 0) {throw 'Signing identity initialization failed.'}
}
$info=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'common\mod.info')
$jarEntry=($info | Where-Object {$_ -match '^javaJarFile='}) -replace '^javaJarFile=',''
$jar=Join-Path $PSScriptRoot ('42.20\'+$jarEntry)
& java $signer sign $KeyDirectory $jar
if($LASTEXITCODE -ne 0) {throw 'JAR signing failed.'}
Copy-Item -LiteralPath (Join-Path $KeyDirectory 'public-profile-key.txt') -Destination (Join-Path $PSScriptRoot 'ZombieBuddy-public-key.txt') -Force
+1
View File
@@ -0,0 +1 @@
JavaModZBS:a4feaa34621f43ec1eb2283771f1120531ccdf44cd2a3f270eaba20515994a21
+11
View File
@@ -0,0 +1,11 @@
name=Military Tool Kit - Viewpoint Turret Aiming
id=MTKViewpointTurrets
description=Local B42.21 compatibility: camera aiming and 3D world tracers for MTK turrets, including the HRSys Ferret and LAV adapters. Hold right mouse to traverse; left mouse fires using normal MTK ballistics and ammunition.
require=\ZombieBuddy,\Military_Tool_Kit,\Viewpoint
loadModAfter=Military_Tool_Kit,Viewpoint,hrsys_functionalturrets_ferret,hrsys_functionalturrets_lav300,KI5FerretTurret,KI5LAVTurret
versionMin=42.21
versionMax=42.21
modversion=0.2.0
javaJarFile=media/java/client/MTKViewpointTurrets-0.2.0.jar
javaPkgName=localmods.mtkviewpoint
ZBVersionMin=2.3.0
@@ -0,0 +1,27 @@
package localmods.mtkviewpoint;
import me.zed_0xff.zombie_buddy.Exposer.LuaClass;
import viewpoint.core.View;
import viewpoint.input.FreeCam;
import viewpoint.input.Look;
/** Read-only bridge to Viewpoint 0.1.6.1; never changes capture or camera state. */
@LuaClass(name = "MTKViewpointCamera")
public final class CameraBridge {
private CameraBridge() {}
public static boolean enabled() {
return View.enabled;
}
public static boolean captured() {
return View.enabled && Look.captured && !FreeCam.active;
}
public static Double heading() {
float yaw = Look.yaw;
if (!captured() || !Float.isFinite(yaw)) return null;
// Viewpoint Controls.face uses cos(yaw), sin(yaw) in game-world XY.
return (Math.toDegrees(yaw) % 360.0 + 360.0) % 360.0;
}
}
+9
View File
@@ -0,0 +1,9 @@
package localmods.mtkviewpoint;
public final class Main {
private Main() {}
public static void main(String[] args) {
System.out.println("[MTKViewpointTurrets] 0.2.0 camera bridge and 3D tracers loaded (Viewpoint 0.1.6.1 / B42.21).");
}
}
@@ -0,0 +1,97 @@
package localmods.mtkviewpoint;
import java.util.*;
import me.zed_0xff.zombie_buddy.Exposer.LuaClass;
import se.krka.kahlua.vm.*;
import viewpoint.core.Frame;
import viewpoint.render.SceneData;
import zombie.Lua.Event;
import zombie.Lua.LuaEventManager;
/** Reads MTK's real projectiles on the game thread; publishes immutable render snapshots. */
@LuaClass(name = "MTKViewpointProjectiles")
public final class ProjectileBridge {
static final int LIMIT = 256;
static final double LEVEL = 2.4494895935058594;
private static KahluaTable physics, visuals;
private static final Map<SceneData, List<TracerMesh.Segment>> frames =
Collections.synchronizedMap(new WeakHashMap<>());
private ProjectileBridge() {}
public static LuaClosure legacyRenderer() {
var events = new ArrayList<Event>();
LuaEventManager.getEvents(events, new HashMap<>());
LuaClosure found = null;
for (var event : events) {
if (!"OnPreUIDraw".equals(event.name)) continue;
for (var callback : event.callbacks) {
var constants = Arrays.asList(callback.prototype.constants);
if (constants.containsAll(List.of("renderline","VisualProjectilePC","currentDistance","startHeight","isCovered"))) {
if (found != null) return null; // ambiguous: leave the dependency untouched
found = callback;
}
}
}
return found;
}
public static boolean bind(Object removeById, KahluaTable visualList) {
// In installed MTK 2.0.0 removeByID closes over exactly one value:
// projectilesByID. Read that reference; never replace or modify it.
if (!(removeById instanceof LuaClosure fn) || fn.prototype.numParams != 2 || fn.upvalues.length != 1
|| !Arrays.asList(fn.prototype.constants).containsAll(List.of("id","list","table","remove"))
|| fn.upvalues[0] == null || !(fn.upvalues[0].getValue() instanceof KahluaTable table)
|| visualList == null) return false;
physics = table;
visuals = visualList;
frames.clear();
return true;
}
public static void capture(Frame frame) {
if (frame == null) return;
if (physics == null || visuals == null) { frames.remove(frame.scene); return; }
var result = new ArrayList<TracerMesh.Segment>();
for (int i = 1, count = Math.min(visuals.len(), LIMIT); i <= count; i++) {
if (!(visuals.rawget(i) instanceof KahluaTable visual)) continue;
if (!(physics.rawget(visual.rawget("id")) instanceof KahluaTable shot)) continue;
double distance = num(shot, "distance"), travelled = num(shot, "currentDistance");
double x = num(shot,"startX"), y = num(shot,"startY"), z = num(shot,"startHeight");
double ex = num(shot,"endX"), ey = num(shot,"endY"), ez = num(shot,"endHeight");
if (!finite(distance,travelled,x,y,z,ex,ey,ez) || distance <= 0 || travelled <= 0 || travelled >= distance) continue;
double scale = num(visual,"scale");
if (!Double.isFinite(scale)) scale = 0.1;
double length = Math.max(0.45, Math.min(2.5, distance * Math.max(0.1,scale) * 0.08));
double head = Math.min(1,travelled/distance), tail = Math.max(0,(travelled-length)/distance);
double hx=x+(ex-x)*head, hy=y+(ey-y)*head, hz=z+(ez-z)*head;
double tx=x+(ex-x)*tail, ty=y+(ey-y)*tail, tz=z+(ez-z)*tail;
float[] h=local(frame.scene,hx,hy,hz), t=local(frame.scene,tx,ty,tz);
KahluaTable color = visual.rawget("color") instanceof KahluaTable c ? c : null;
result.add(new TracerMesh.Segment(h[0],h[1],h[2],t[0],t[1],t[2],
color(color,1,1),color(color,2,0.72f),color(color,3,0.14f),1));
}
frames.put(frame.scene,List.copyOf(result));
}
static List<TracerMesh.Segment> segments(SceneData scene) {
return frames.getOrDefault(scene,List.of());
}
static float[] local(SceneData scene, double x, double y, double z) {
double lift=scene.relief == null ? 0 : scene.relief.scene(x,y);
return new float[]{(float)(-x-scene.originX),(float)(z*LEVEL+lift-scene.originY),(float)(-y-scene.originZ)};
}
private static double num(KahluaTable t,String key) {
return t.rawget(key) instanceof Number n ? n.doubleValue() : Double.NaN;
}
private static float color(KahluaTable t,int key,float fallback) {
return t != null && t.rawget(key) instanceof Number n && Float.isFinite(n.floatValue())
? Math.max(0,Math.min(1,n.floatValue())) : fallback;
}
private static boolean finite(double... values) {
for (double v:values) if (!Double.isFinite(v)) return false;
return true;
}
}
@@ -0,0 +1,26 @@
package localmods.mtkviewpoint;
import me.zed_0xff.zombie_buddy.Patch;
import viewpoint.core.Frame;
import viewpoint.render.SceneData;
public final class RenderPatches {
private RenderPatches() {}
@Patch(className="viewpoint.FP",methodName="captureWorld")
public static class Snapshot {
@Patch.OnExit
public static void exit(@Patch.Argument(0) Frame frame) {
try { ProjectileBridge.capture(frame); }
catch (Throwable error) { TracerRenderer.fail(error); }
}
}
@Patch(className="viewpoint.render.WorldRenderer",methodName="drawWeather")
public static class Draw {
@Patch.OnExit
public static void exit(@Patch.Argument(0) SceneData scene) {
TracerRenderer.draw(scene);
}
}
}
@@ -0,0 +1,38 @@
package localmods.mtkviewpoint;
import java.nio.FloatBuffer;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/** Projected ribbons retain world depth; clipping prevents flashes behind the camera. */
public final class TracerMesh {
public record Segment(float hx,float hy,float hz,float tx,float ty,float tz,float r,float g,float b,float alpha) {}
public static final int STRIDE=10;
private TracerMesh() {}
public static int append(FloatBuffer out, Segment s, Matrix4f vp, int width, int height) {
if (width<=0 || height<=0 || out.remaining()<6*STRIDE) return 0;
var h=vp.transform(new Vector4f(s.hx,s.hy,s.hz,1));
var t=vp.transform(new Vector4f(s.tx,s.ty,s.tz,1));
if (!h.isFinite() || !t.isFinite()) return 0;
float hn=h.z+h.w, tn=t.z+t.w;
if (hn<=0 && tn<=0) return 0;
if (hn<=0) h.lerp(t,(-hn+0.00001f)/(tn-hn));
else if (tn<=0) t.lerp(h,(-tn+0.00001f)/(hn-tn));
if (h.w<=0.000001f || t.w<=0.000001f) return 0;
float dx=(h.x/h.w-t.x/t.w)*width, dy=(h.y/h.w-t.y/t.w)*height;
float len=(float)Math.hypot(dx,dy);
if (len<1) { // End-on rounds still have a small visible core.
t.x-=4f/width*t.w; dx=4; dy=0; len=4;
}
float ox=-dy/len*2.5f/width, oy=dx/len*2.5f/height;
vertex(out,t,ox,oy,-1,0,s); vertex(out,h,ox,oy,-1,1,s); vertex(out,h,ox,oy,1,1,s);
vertex(out,t,ox,oy,-1,0,s); vertex(out,h,ox,oy,1,1,s); vertex(out,t,ox,oy,1,0,s);
return 6;
}
private static void vertex(FloatBuffer b,Vector4f p,float ox,float oy,float side,float along,Segment s) {
b.put(p.x+ox*side*p.w).put(p.y+oy*side*p.w).put(p.z).put(p.w);
b.put(along).put(side).put(s.r).put(s.g).put(s.b).put(s.alpha);
}
}
@@ -0,0 +1,121 @@
package localmods.mtkviewpoint;
import java.lang.reflect.Field;
import java.nio.FloatBuffer;
import org.lwjgl.BufferUtils;
import static org.lwjgl.opengl.GL33.*;
import viewpoint.render.FrameContext;
import viewpoint.render.SceneData;
import viewpoint.render.WorldRenderer;
/** Native Viewpoint HDR pass, before post processing, with normal scene depth testing. */
public final class TracerRenderer {
private static final FloatBuffer vertices=BufferUtils.createFloatBuffer(ProjectileBridge.LIMIT*6*TracerMesh.STRIDE);
private static Field frameField,irisField;
private static int program,vao,vbo;
private static volatile boolean failed;
private TracerRenderer() {}
public static void fail(Throwable error) {
if (!failed) System.out.println("[MTKViewpointTurrets] 3D tracers disabled after render error: "+error);
failed=true;
}
public static void draw(SceneData scene) {
if (failed || scene==null) return;
var shots=ProjectileBridge.segments(scene);
if (shots.isEmpty()) return;
try {
if (frameField==null) {
frameField=WorldRenderer.class.getDeclaredField("frame"); frameField.setAccessible(true);
irisField=WorldRenderer.class.getDeclaredField("irisDrawn"); irisField.setAccessible(true);
}
if (irisField.getBoolean(null)) return; // Minecraft/Iris uses separate private targets.
FrameContext frame=(FrameContext)frameField.get(null);
if (frame==null || frame.scene!=scene) return;
vertices.clear();
int count=0;
for (var shot:shots) count+=TracerMesh.append(vertices,shot,frame.viewProjection,frame.width,frame.height);
vertices.flip();
if (count==0) return;
drawBuffer(vertices,count,frame.width,frame.height,true);
} catch (Throwable error) { fail(error); }
}
// Kept separate so the same production GPU path can be checked in a tiny
// hidden OpenGL test context without launching Project Zomboid.
static void drawBuffer(FloatBuffer data,int count,int width,int height,boolean bindViewpointTarget) {
int oldProgram=glGetInteger(GL_CURRENT_PROGRAM),oldVAO=glGetInteger(GL_VERTEX_ARRAY_BINDING);
int oldVBO=glGetInteger(GL_ARRAY_BUFFER_BINDING),oldDraw=glGetInteger(GL_DRAW_FRAMEBUFFER_BINDING);
int oldRead=glGetInteger(GL_READ_FRAMEBUFFER_BINDING),depthFunc=glGetInteger(GL_DEPTH_FUNC);
int[] viewport=new int[4]; glGetIntegerv(GL_VIEWPORT,viewport);
boolean depth=glIsEnabled(GL_DEPTH_TEST),blend=glIsEnabled(GL_BLEND),cull=glIsEnabled(GL_CULL_FACE);
boolean scissor=glIsEnabled(GL_SCISSOR_TEST),depthMask=glGetBoolean(GL_DEPTH_WRITEMASK);
int sr=glGetInteger(GL_BLEND_SRC_RGB),dr=glGetInteger(GL_BLEND_DST_RGB);
int sa=glGetInteger(GL_BLEND_SRC_ALPHA),da=glGetInteger(GL_BLEND_DST_ALPHA);
int er=glGetInteger(GL_BLEND_EQUATION_RGB),ea=glGetInteger(GL_BLEND_EQUATION_ALPHA);
try {
if (program==0) init();
if (bindViewpointTarget) WorldRenderer.bindTarget(); else glViewport(0,0,width,height);
glUseProgram(program); glBindVertexArray(vao); glBindBuffer(GL_ARRAY_BUFFER,vbo);
glBufferData(GL_ARRAY_BUFFER,data,GL_STREAM_DRAW);
glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LEQUAL); glDepthMask(false);
glDisable(GL_CULL_FACE); glDisable(GL_SCISSOR_TEST); glEnable(GL_BLEND);
glBlendEquationSeparate(GL_FUNC_ADD,GL_FUNC_ADD);
glBlendFuncSeparate(GL_ONE,GL_ONE_MINUS_SRC_ALPHA,GL_ZERO,GL_ONE);
glDrawArrays(GL_TRIANGLES,0,count);
} finally {
glUseProgram(oldProgram); glBindVertexArray(oldVAO); glBindBuffer(GL_ARRAY_BUFFER,oldVBO);
glBlendEquationSeparate(er,ea); glBlendFuncSeparate(sr,dr,sa,da);
toggle(GL_BLEND,blend); toggle(GL_DEPTH_TEST,depth); toggle(GL_CULL_FACE,cull); toggle(GL_SCISSOR_TEST,scissor);
glDepthFunc(depthFunc); glDepthMask(depthMask);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER,oldDraw); glBindFramebuffer(GL_READ_FRAMEBUFFER,oldRead);
glViewport(viewport[0],viewport[1],viewport[2],viewport[3]);
}
}
private static void toggle(int cap,boolean on) { if(on) glEnable(cap); else glDisable(cap); }
private static void init() {
int vs=shader(GL_VERTEX_SHADER,"""
#version 330 core
layout(location=0) in vec4 clipPosition;
layout(location=1) in vec2 uv;
layout(location=2) in vec4 color;
out vec2 texCoord;
out vec4 tint;
void main(){gl_Position=clipPosition; texCoord=uv; tint=color;}
""");
int fs=shader(GL_FRAGMENT_SHADER,"""
#version 330 core
in vec2 texCoord;
in vec4 tint;
out vec4 fragColor;
void main(){
float edge=1.0-smoothstep(0.25,1.0,abs(texCoord.y));
float a=tint.a*edge*smoothstep(0.0,0.25,texCoord.x);
fragColor=vec4(tint.rgb*5.0*a,a);
}
""");
int linked=glCreateProgram();
try {
glAttachShader(linked,vs); glAttachShader(linked,fs); glLinkProgram(linked);
if(glGetProgrami(linked,GL_LINK_STATUS)==0) throw new IllegalStateException(glGetProgramInfoLog(linked));
} catch(RuntimeException error) {glDeleteProgram(linked); throw error;}
finally {glDeleteShader(vs); glDeleteShader(fs);}
vao=glGenVertexArrays(); vbo=glGenBuffers();
glBindVertexArray(vao); glBindBuffer(GL_ARRAY_BUFFER,vbo);
int stride=TracerMesh.STRIDE*Float.BYTES;
glEnableVertexAttribArray(0); glVertexAttribPointer(0,4,GL_FLOAT,false,stride,0L);
glEnableVertexAttribArray(1); glVertexAttribPointer(1,2,GL_FLOAT,false,stride,16L);
glEnableVertexAttribArray(2); glVertexAttribPointer(2,4,GL_FLOAT,false,stride,24L);
program=linked;
}
private static int shader(int type,String source) {
int id=glCreateShader(type); glShaderSource(id,source); glCompileShader(id);
if(glGetShaderi(id,GL_COMPILE_STATUS)==0) {
String error=glGetShaderInfoLog(id); glDeleteShader(id); throw new IllegalStateException(error);
}
return id;
}
}
+54
View File
@@ -0,0 +1,54 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.*;
import java.security.spec.*;
import java.util.*;
/** Local Ed25519 ZBS signing; private key material is never printed or packaged. */
public class SignZBS {
public static void main(String[] args) throws Exception {
if(args.length<3) throw new IllegalArgumentException("init <key-directory> <SteamID64> | sign <key-directory> <jar>");
Path dir=Path.of(args[1]);
Path privatePath=dir.resolve("ed25519-private.der"), publicPath=dir.resolve("ed25519-public.der");
Path authorPath=dir.resolve("author.properties");
if(args[0].equals("init")) {
if(!args[2].matches("[0-9]{17}")) throw new IllegalArgumentException("Invalid SteamID64");
Files.createDirectories(dir);
if(Files.exists(privatePath)||Files.exists(publicPath)||Files.exists(authorPath))
throw new IllegalStateException("Signing identity already exists; reuse it rather than replacing it.");
KeyPair pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Files.write(privatePath,pair.getPrivate().getEncoded(),StandardOpenOption.CREATE_NEW);
Files.write(publicPath,pair.getPublic().getEncoded(),StandardOpenOption.CREATE_NEW);
Files.writeString(authorPath,"steamID64="+args[2]+"\n",StandardOpenOption.CREATE_NEW);
Files.writeString(dir.resolve("public-profile-key.txt"),profileKey(pair.getPublic())+"\n",StandardOpenOption.CREATE_NEW);
System.out.println("Created dedicated Ed25519 ZombieBuddy signing identity for SteamID64 "+args[2]+".");
return;
}
if(!args[0].equals("sign")) throw new IllegalArgumentException("Unknown operation");
Properties author=new Properties();
try(var reader=Files.newBufferedReader(authorPath)) {author.load(reader);}
String sid=author.getProperty("steamID64");
if(sid==null||!sid.matches("[0-9]{17}")) throw new IllegalStateException("Invalid signing identity");
var factory=KeyFactory.getInstance("Ed25519");
var privateKey=factory.generatePrivate(new PKCS8EncodedKeySpec(Files.readAllBytes(privatePath)));
var publicKey=factory.generatePublic(new X509EncodedKeySpec(Files.readAllBytes(publicPath)));
Path jar=Path.of(args[2]);
String sha=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(jar)));
byte[] payload=("ZBS:"+sid+":"+sha).getBytes(StandardCharsets.UTF_8);
var signer=Signature.getInstance("Ed25519");signer.initSign(privateKey);signer.update(payload);
byte[] signature=signer.sign();
var verifier=Signature.getInstance("Ed25519");verifier.initVerify(publicKey);verifier.update(payload);
if(!verifier.verify(signature)) throw new IllegalStateException("Signing key pair does not match");
Path sidecar=Path.of(jar.toString()+".zbs");
Files.writeString(sidecar,"ZBS\nSteamID64:"+sid+"\nSignature:"+HexFormat.of().formatHex(signature)+"\n");
System.out.println("Signed and cryptographically verified "+jar.getFileName()+" (SteamID64 "+sid+").");
System.out.println("SHA-256: "+sha);
}
private static String profileKey(PublicKey key) {
byte[] encoded=key.getEncoded();
byte[] prefix=HexFormat.of().parseHex("302a300506032b6570032100");
if(encoded.length!=44||!Arrays.equals(prefix,Arrays.copyOf(encoded,12)))
throw new IllegalStateException("Unexpected Ed25519 public key encoding");
return "JavaModZBS:"+HexFormat.of().formatHex(Arrays.copyOfRange(encoded,12,44));
}
}