Description
Requirements
- Minecraft 1.21.1, NeoForge 21.1.248 or newer
- Vista 5.4.4+ and its library Moonlight 3.5.2+
- CC:Tweaked 1.115+
- Sable 2.0.3+ (optional).
Thermal view without a computer
Insert the tinted glass into the camera slot to achieve a thermal imaging effect. The other functions are accessible only via a computer.
The peripheral
local cam = peripheral.find("thermal_camera")
cam.setThermal(true) -- same as the tinted glass, but from Lua
cam.setReticle(true) -- black crosshair in the centre of the feed
cam.setOverlayText("READY") -- text at the bottom of the feed
A short list of what the computer can do:
- aim the camera in absolute angles or nudge it by a delta
- change the zoom
- stabilize the camera against the vehicle it is mounted on (Sable), either fully in the world frame or pitch-only with the heading following the vehicle
- point it at a world coordinate and let it lock onto the nearest Sable structure there, then follow that structure automatically
- add an operator correction on top of the automatic tracking (lead, elevation) without losing the lock
- read the tracked structure's position, bounding box, range, angular error and velocity
- list every structure currently in the camera's field of view
- fire a rangefinder along the line of sight and get back the block it hits
- draw a reticle and up to 256 characters of overlay text on the feed
- read the camera's true world direction at any time, cheaply
Stabilization modes
While a computer is attached, this mod runs a small controller on the server every tick that keeps a "held" direction for the camera and re-applies it in whatever frame you ask for:
setStabilized(false): held angles are local to the block. The camera is rigid.setStabilized(true): held angles are in the world frame. The vehicle can turn, pitch or roll and the camera keeps looking the same way.setStabilized(true, true): pitch-only. Heading follows the vehicle (useful when the camera sits on a turret that a separate motor already rotates), pitch is held in the world frame so the horizon stays level when the hull rocks.
Cue, lock, track
cue(x, y, z) tells the camera to swing toward a world point (with a slew limit of 6 degrees per tick, so it looks like a gimbal and not a teleport). While it is looking there it searches for Sable structures within a 3 degree cone of its line of sight. The one closest to the line of sight gets locked. From then on the camera follows that structure by itself: every tick it recomputes the bearing to the structure's aim point and applies it, with the same slew limit.
The aim point is not the centre of the structure by default. setAimHeightFraction(f) picks a height inside the structure's bounding box, 0 for the bottom, 0.5 for the centre, 1 for the top. It defaults to 0.5. The same point is returned by getTrack(), so a gun controller and the camera agree on where "the target" is.
While locked, correct(dYaw, dPitch) shifts the camera's line of sight away from the structure by a running offset (clamped to 20 degrees). The structure drifts off the crosshair by that amount and the tracking continues underneath. This is how an operator can put lead on a moving target while the automation keeps holding it. clearCorrection() zeroes it, and any manual aim command (setAim, nudge, cue, unlock) drops the lock and the correction together.
The lock is dropped automatically when the structure is removed from the world. Distance alone does not break it.
The camera excludes the structure it is standing on from the search, so a camera on a tank does not lock onto that tank.
The rangefinder
rangefind() casts a ray from the camera along its current line of sight, in 0.5 block steps, until it hits a world block with a collision shape, up to 1500 blocks. It returns the hit point, the block position and the distance, or nil if the ray leaves the loaded area or hits nothing. It does not force chunks to load and it does not see Sable structures (they live in Sable's own grid, not in world block space; use getContacts() for them).
API reference
Angles are radians. Yaw follows the Minecraft entity convention: 0 is south (+Z), positive turns toward west (-X). Pitch is negative upward, positive downward. Velocities are blocks per tick. Coordinates are world coordinates, already projected out of the Sable grid if the camera is on a sublevel.
The "main thread" column matters for performance. Those calls run on the server thread and the calling computer waits for the next server tick each time. The others return immediately. If you poll a main-thread function twenty times a second, your program will spend most of its time waiting. See the performance notes below.
| Function | Main thread | What it does |
|---|---|---|
setAim(yaw, pitch) |
yes | Absolute aim in the current frame (local, world, or mixed, depending on setStabilized). Drops lock, cue and correction. |
nudge(dYaw, dPitch) |
yes | Relative aim. Same side effects as setAim. |
getAimWorld() |
no | Returns yaw, pitch of where the camera actually points, in the world frame. Cheap; call it as often as you like. |
setZoom(level) / getZoom() |
yes / no | Vista zoom level, the same integer the viewfinder GUI uses. |
setStabilized(on [, pitchOnly]) / isStabilized() / isPitchOnly() |
yes / no / no | Stabilization mode, see above. Persists with the block. |
setAimHeightFraction(f) / getAimHeightFraction() |
no | Where inside a locked structure's bounding box the camera aims. |
cue(x, y, z) |
yes | Swing toward a world point and try to lock the nearest structure there. |
unlock() |
yes | Drop the lock or the cue. The camera stays where it is. |
isLocked() |
no | Whether a structure is currently locked. |
correct(dYaw, dPitch) |
yes | Add to the operator offset while locked. Returns false if not locked. |
clearCorrection() / getCorrection() |
yes / no | Zero the offset / read it as yaw, pitch. |
getTrack() |
yes | State of the locked structure, see the table below. |
getContacts() |
yes | Every Sable structure inside the field of view, up to 1500 blocks, in no particular order. |
rangefind() |
yes | Block rangefinder along the line of sight. |
setThermal(on) / isThermal() |
yes / no | Thermal view from Lua. isThermal() is also true when tinted glass is in the lens slot; the glass cannot be overridden from Lua. |
setReticle(on) / isReticle() |
yes / no | Black crosshair at the centre of the feed. |
setOverlayText(s) / getOverlayText() |
yes / no | Text at the bottom of the feed, lines separated by \n, up to 256 characters, UTF-8. |
getDebug() |
yes | Everything the controller knows: own position and frame, current and held angles, half FOV, and the unfiltered list of all sublevels with their bearings. For debugging a setup, not for the control loop. |
getTrack() returns a table:
| Key | Meaning |
|---|---|
locked |
false if nothing is locked; the other keys are absent then |
sid |
structure id (a string), stable for the lifetime of the structure |
x, y, z |
aim point in world coordinates (bounding box centre horizontally, aimFrac of the height vertically) |
ymin, ymax, aimFrac |
bounding box height range and the fraction in use |
range |
distance from the camera to the aim point |
yawErr, pitchErr |
bearing to the aim point minus the camera's current direction |
aimYaw, aimPitch |
where the camera is currently pointing (world) |
corrYaw, corrPitch |
the operator offset in effect |
vx, vy, vz |
velocity of the aim point, blocks per tick, smoothed over recent server ticks |
velSamples |
how many ticks went into that estimate; trust it once this is 3 or more |
getContacts() returns a list of tables with sid, x, y, z (structure centre), ymin, ymax, range, yawErr and pitchErr.
rangefind() returns range, x, y, z (hit point) and bx, by, bz (block position), or nil.
Example
Lock the structure closest to the crosshair and print its range while it is tracked:
local cam = peripheral.find("thermal_camera")
if not cam then error("no thermal_camera attached") end
cam.setStabilized(true)
cam.setReticle(true)
-- pick the contact nearest to the centre of the frame
local best, bestErr
for _, c in ipairs(cam.getContacts()) do
local err = c.yawErr ^ 2 + c.pitchErr ^ 2
if not bestErr or err < bestErr then best, bestErr = c, err end
end
if not best then error("nothing in view") end
cam.cue(best.x, best.y, best.z)
while true do
local t = cam.getTrack() -- main thread: once per 5 ticks is plenty
if not t.locked then
cam.setOverlayText("LOST")
break
end
local speed = math.sqrt(t.vx ^ 2 + t.vy ^ 2 + t.vz ^ 2) * 20 -- blocks/tick -> blocks/s
cam.setOverlayText(("RANGE %d m V %.1f m/s"):format(math.floor(t.range + 0.5), speed))
sleep(0.25)
end
Laser-range whatever the camera is looking at:
local r = cam.rangefind()
if r then
print(("block %d %d %d at %.1f m"):format(r.bx, r.by, r.bz, r.range))
else
print("no hit")
end
Performance notes
Main-thread calls are the expensive part. Each one is a round trip to the server tick, so a loop that calls getTrack() every tick runs at a fraction of the speed it could.
- Call
getTrack()orgetContacts()every 3 to 5 ticks and extrapolate between samples withvx/vy/vz. - Use
getAimWorld()(not main thread) whenever you just need to know where the camera points. - Compare overlay text on the Lua side before calling
setOverlayText(). The mod also skips unchanged text, but the call still costs a tick. - Gate
setAim()behind a small threshold instead of sending it every tick.
The controller itself (stabilization and tracking) runs on the server tick and costs the computer nothing.
Limitations
getContacts()does not check line of sight. A structure behind a hill is still listed if it is inside the cone.rangefind()cannot hit Sable structures.- Cue picks by angle only. Whatever is nearest to the line of sight inside the 3 degree cone gets locked, even if it is a small structure a few blocks away and you meant the one behind it. Filter
getContacts()byrangeon the Lua side before cueing if that matters. - The thermal image is a rendering trick, not a temperature model. Sable structures are hot, everything else is cold, and that is the whole rule.
For modpack authors
Client and server both need the mod. The peripheral and the controller are server side; the thermal rendering is client side and needs the mixins in vista_thermal_camera.mixins.json, which target Vista 5.4.x, Sable's render dispatcher, and (only when present) Sodium's cloud renderer and Distant Horizons.
Credits
Vista and Moonlight are by MehVahdJukaar. CC:Tweaked is by SquidDev. Sable is by RyanHCode. This mod is glue between the three; none of the hard parts are mine.
MIT license. Do what you like with it, keep the copyright notice.

