Freelancer's .sur collision files. Unlike meshes and models, a surface library is not a UTF tree
— it is a standalone chunked binary that pairs with a model by part ID, so the readers take a
BufferView. Export names are in API.md.
A surface library is a flat list of parts. Each part carries a bounding box, a bounding sphere, a list of hardpoint IDs, and a bounding-volume hierarchy whose leaves are convex hulls built from shared points.
The surf chunk is not a Freelancer invention. It is a verbatim memory image of
IVP_Compact_Surface from Ipion Virtual Physics — the middleware that later became Havok.
Freelancer wraps it in a thin FourCC container (vers, !fxd, exts, surf, hpid) and stores a
part CRC where IVP stores user data.
Source: ChimpsAtSea/Ipion-Virtual-Physics,
chiefly ivp_surface_manager/ivp_compact_surface.hxx,
ivp_collision/ivp_compact_ledge.hxx and the builder in
ivp_compact_builder/ivp_surbuild_ledge_soup.cxx. Check any layout change against it.
| This module | IVP class | Size |
|---|---|---|
Surface |
IVP_Compact_Surface |
48 bytes (header) |
Node |
IVP_Compact_Ledgetree_Node |
28 bytes |
Hull |
IVP_Compact_Ledge |
16 bytes (header) |
Face |
IVP_Compact_Triangle + 3 × IVP_Compact_Edge |
16 bytes |
Point |
IVP_Compact_Poly_Point (an IVP_U_Float_Hesse) |
16 bytes |
Two constants explain most of the magic numbers below. IVP_COMPACT_BOUNDINGBOX_STEP_SIZE and
IVP_COMPACT_SURFACE_DEVIATION_STEP_SIZE are both 1.0f / 250.0f, and 250 is 0xfa — the divisor
applied to Node.boxSizes and Surface.surfaceDeviation.
Field names follow IVP's meaning in this repository's camelCase style rather than its snake_case
identifiers:
| This module | IVP field |
|---|---|
Surface.massCenter |
mass_center |
Surface.rotationInertia |
rotation_inertia (MAXLancer names the same slot drag) |
Surface.radius |
upper_limit_radius |
Surface.surfaceDeviation |
max_factor_surface_deviation |
Surface.padding |
dummy[3] |
Node.boxSizes |
box_sizes |
Node.padding |
free_0 |
Hull.reserved |
for_future_use |
Face.material / Face.virtual |
material_index / is_virtual |
Face.pierce |
pierce_index |
Face.points |
per-edge start_point_index |
Face.opposites / Face.virtualEdges |
per-edge opposite_index / is_virtual |
Point.clientData |
hesse_val, reused as client_data |
Surface library (.sur)
header ── 'vers' + float32 2.0
└─ Part[] ── one per collision-enabled model part
├─ '!fxd' ── part is not fixed to the root
├─ 'exts' → Extent ── bounding box
├─ 'surf' → Surface ── IVP_Compact_Surface
│ ├─ massCenter, rotationInertia, radius, surfaceDeviation
│ ├─ Hull[] ── ledges
│ ├─ Point[] ── shared hull vertices
│ └─ Node ── ledgetree root
│ ├─ left / right ── child nodes
│ └─ hull? ── Hull → Face[]
└─ 'hpid' → number[] ── hardpoint IDs
A part is matched to a model part by id, the CRC32 of the part name.
The file begins with the signature vers (0x73726576) followed by a float32 version, which must
be exactly 2.0. Parts are then read until the view is exhausted.
Everything the format records but does not let a writer omit — half-edge adjacency, pierce indices, the bounding spheres and their quantized boxes, the mass properties — is derivable from the triangles, and this module derives it. See Building a hitbox.
interface Part extends Extent, Surface {
id: number // int32 — CRC32 of the model part name
fixed: boolean // false when the '!fxd' chunk is present
hardpoints: number[] // int32 CRCs of hardpoints this part covers
}| Field | Type | Notes |
|---|---|---|
id |
int32 | Part identifier |
| count | uint32 | Number of chunks that follow |
| chunks | — | Each prefixed by a uint32 FourCC |
| FourCC | Value | Payload |
|---|---|---|
!fxd |
0x64786621 |
None — marks the part as not fixed |
exts |
0x73747865 |
Extent (24 bytes) |
surf |
0x66727573 |
uint32 size, then the Surface block |
hpid |
0x64697068 |
uint32 count followed by that many int32 IDs |
Chunk payloads are not length-prefixed at the tag level, so an unrecognized tag cannot be skipped —
continuing past one would silently desynchronize the rest of the file. readPart throws a
RangeError instead.
There are two rules and the id-by-name one is only the second. A part built for a non-compound
model carries id 0, not the CRC of anything — MAXLancer's SurfacePart.Parse sets
partID = if compound then Hash target.name else 0, and SurfaceLibrary.Build looks it back up the
same way. So a consumer resolves a part as:
id === 0→ the model root, for a.3dbwith noCmpnd.id === getResourceId(partName)→ that compound part.
Measured over the 1,308 parts in the 735 retail .sur files with a sibling model: 549 match by the
zero, 747 by name, 12 by neither. The zero is not an edge case — it is 549 of the 551 single-part
.3db surfaces — so a reader implementing rule 2 alone loses every one of them, silently.
Compare ids unsigned. readPart reads id with readInt32 and readHull with readUint32,
while getResourceId returns a signed int32, so a CRC with the high bit set matches neither under a
bare ===.
fixed is the inverse of the !fxd chunk: a part reads back as fixed: true when the chunk is
absent, and on write the chunk is emitted whenever fixed is falsy. The hpid chunk is written only
when hardpoints is non-empty, matching Freelancer. Chunks are written in Freelancer's order: !fxd
(conditional), exts, surf, hpid (conditional).
interface Extent {
minimum: Vector3
maximum: Vector3
}Fixed size: 24 bytes.
Component order: stored as two contiguous XYZ vectors —
min.x, min.y, min.z, max.x, max.y, max.z. This does not followVMeshRef's interleaved convention.
The extent bounds the part, which for a compound part is not obliged to enclose every hull.
createBox indexes its corners by axis bit — corner n takes the maximum on axis a when bit a of
n is set — so corner 0 is the minimum and corner 7 the maximum. It is the shortest path to a
hitbox: createPart(id, [{ id, ...createBox(extent) }]).
interface Surface {
massCenter: Vector3 // also the bounding sphere centre; used for the aiming reticle
rotationInertia: Vector3 // not a drag coefficient — debris tumbles by it, observed in game
radius: number // must encompass every hull in the part
surfaceDeviation: number // quantized in steps of 1/250
points: Point[] // shared hull vertices
root: Node // ledgetree root
padding: Vector3 // 16-byte alignment padding
}The block is preceded by a uint32 size, which duplicates the byte_size field inside the header.
Then a 48-byte header:
| Field | Type | IVP name |
|---|---|---|
massCenter |
float32×3 | mass_center |
rotationInertia |
float32×3 | rotation_inertia |
radius |
float32 | upper_limit_radius |
| header | uint32 | Low byte = max_factor_surface_deviation; upper 24 bits = byte_size |
startOffset |
int32 | offset_ledgetree_root |
padding |
int32×3 | dummy[3] |
Hulls, points and nodes follow, in that order — the layout IVP_SurfaceBuilder_Ledge_Soup emits.
Offsets inside the block are relative to the field that holds them, so the block is position
independent. The reader walks the tree from startOffset with a queue, resolving each node's
right-child and hull offsets; offsets outside [startOffset, byte_size] raise a RangeError. Each
hull records a relative offset to the shared point block; points are read from there up to
startOffset.
Descent stops at a node whose hull has type Enabled; nodes with no hull or with a Skip hull
continue into their children. IVP's own test is offset_right_node != 0, which agrees on well-formed
data because a leaf node always carries a terminal ledge.
surfaceDeviation is a deviation, not a scale factor: multiply it by radius to recover how far
the surface departs from the bounding sphere. The builder computes it as
int(1 + deviation / (radius / 250)).
MAXLancer reads this byte as something else, and the disagreement is unresolved. Its
SurfacePart.scalerisminRadius / maxRadius, wheremaxRadiuscovers every point of the part andminRadiusonly the points of hulls whose id is not in thehpidlist — a bounding-sphere scale that deliberately excludes hardpoint hulls. Both readings divide by 250 and land in the same range, so this module's reproducing retail in 1,349 of 1,365 does not by itself separate them. That reading is more plausible than it first looks, now that hardpoint hulls are known to be a real category — 1,272 of the 9,111. Nothing here depends on the answer, since the field is written from the derivation and round-trips either way. It is settleable by measurement, not by observation, which is why it is not in TODO: compute both quantities over the corpus and compare each against the stored byte, on the parts that discriminate — those whose hardpoint hulls reach further than the rest, whereminRadius / maxRadiusdeparts from 1.
getMassProperties is IVP's insert_radius_in_compact_surface, and reads the terminal hulls
only, skipping anything that merely bounds a subtree, exactly as IVP's get_all_ledges does.
massCenteris the centroid of the volume the hulls enclose, by the divergence theorem: each face spans a tetrahedron with the origin of signed volumedot(normal, a) / 6and centroid the mean of its four points. A hull enclosing no volume — the flat two-face hulls the corpus is full of — has nothing to integrate, and IVP falls back to the centre of the bounding box, tested as the summed determinant against the summed area to the power of three halves.radiusis how far the farthest point sits frommassCenter.surfaceDeviationcomes off the same pass: the deviation is how far a point sits off the axis throughmassCenteralong its own face's normal, and the byte isint(1 + deviation / (radius / 250)).rotationInertiaintegrates each axis with the other two rotated into place and combines the three second moments pairwise. Where the hull encloses no volume it takes the same fallback,0.5 × r²uniform across the three axes.
Three of the four reproduce retail outright; rotationInertia does not, and TODO says why.
interface Node {
center: Vector3 // boundary center, in object coordinates
radius: number // bounding sphere radius
boxSizes: Vector3 // stored as uint8 / 250
padding: number // always zero
hull?: Hull // leaf hull, when present
left?: Node
right?: Node
}Fixed size: 28 bytes — right-child offset (int32), hull offset (int32), then a 20-byte payload. A zero offset means "absent"; the left child, when present, immediately follows its parent.
Multiply boxSizes by radius to get the half-extents of the axis-aligned box around center.
A well-formed tree is a full binary tree, so nodeCount == 2 × terminalHulls − 1.
A leaf's sphere is centred on its hull's bounding box and reaches the corners of it, and each box size
is int(halfExtent / (radius / 250)) + 1 — truncated and then stepped past, so the quantized box
always contains the one it came from. The half-extent never exceeds the radius, so the count never
exceeds 251 and always fits its byte.
mergeNodes unions the children's quantized boxes rather than their spheres, which is what lets a
parent's bounds follow from what was written for its children without visiting anything below again.
Both rules are IVP's ledges_to_boxes_and_spheres and build_minimal_sphere.
createHierarchy merges whichever pair of nodes yields the tightest bounds until one is left, which
is the rule IVP's sphere clustering minimizes — without the interval hash it uses to avoid comparing
every pair. Inner nodes come back without a hull; createPart is what fills them.
enum HullType {
Enabled = 4, // terminal ledge; `id` is the model part ID
Skip = 5, // bounds a subtree; `id` is an offset back to the owning node
}
interface Hull {
id: number
type: HullType
faces: Face[]
reserved: number // always zero
}| Field | Type | IVP name |
|---|---|---|
| point offset | int32 | c_point_offset, relative to the hull |
id |
int32 | ledgetree_node_offset / client_data |
| header | uint32 | Low byte = type flags; upper 24 bits = size_div_16 |
| face count | int16 | n_triangles |
reserved |
int16 | for_future_use |
| faces | — | 16 bytes each |
HullType is not an enum in IVP but two packed flags: has_children (bits 0–1) and is_compact
(bits 2–3). Every hull Freelancer emits is compact, so only 4 and 5 occur.
That distinction is what gives id two meanings: it is IVP's
union { ledgetree_node_offset; client_data; }. A terminal ledge stores user data — Freelancer's part
CRC, which may differ from the id of the surface part containing it — while a subtree-bounding ledge
stores a relative offset back to the node that owns it.
A terminal hull's
idis a label, not a coordinate frame. Its points are in the frame of the part containing it, whatever the id names. MAXLancer'sSurfaceLibrary.GetPartSurfaceswalks descendant parts joined by a fixed joint and folds their hulls into the nearest non-fixed ancestor's part, reading every vertexin coordsys target;SurfacePart.Buildrestates it by hanging each rebuilt hull athull.transform = target.transform, with no per-hull transform anywhere. Scored against the named part's own mesh bounding sphere over the 1,801 retail hulls where the two candidate frames differ, the containing part wins 1,753 to 48. The id survives as provenance — which part or hardpoint the geometry came from, for damage attribution.It differs from its part's id in 3,164 of the 9,111 terminal hulls and 321 of 1,365 parts hold hulls under more than one id, so reading it as a frame misplaces a third of the corpus — and still draws something.
The fold does not move a hull, it copies it. SurfaceLibrary.Parse runs GetPartSurfaces for
every part, and the descent into fixed children is transitive, so a hull on a fixed-jointed child is
written into that child's own part and into each of its fixed ancestors' — the same geometry,
re-expressed once per frame it lands in. The game wants that: a parent answers for its whole fixed
subtree without descending into it. It reaches hardpoint hulls as well as part hulls, and the
ancestor's hpid chunk grows to list the descendants' hardpoints accordingly, which is why a root's
hpid is routinely a superset of the hardpoints its own part carries. Over the retail files with a
sibling model, 1,377 of 8,982 placeable hulls across 62 files are copies of another part's;
freeport7_dmg.sur is exactly half. A reader that wants each hull once drops those whose id names a
part reached through an unbroken chain of fixed joints that holds them itself — MAXLancer's import
default, its "Keep Duplicates" checkbox being the opt-out — and a reader that wants the file as the
game sees it keeps them. This library does neither: readSurfaceLibrary reports what the bytes
say, and which of the two a consumer wants is a policy.
A hull id is one of three things, and hpid is what tells them apart. GetPartSurfaces also
collects HardpointHelper children carrying a hull shape, hashing the hardpoint's name for the
id, and SurfacePart.hardpoints — the hpid chunk — ends up holding exactly that set. So across the
9,111 terminal hulls: 1,272 have an id listed in their part's hardpoints and are collision
volumes on a hardpoint, 5,314 name a model part, and 2,525 are 0. writeSurface recomputes the type-5 value,
since it depends on where the node tree lands.
size_div_16 is the whole ledge size in 16-byte units: one header, one per triangle, one per point.
Since a closed convex polyhedron has V = 2 + F / 2 by Euler's formula, this reduces to
(12 + faceCount × 6) / 4, which is what getIndexCount computes and validates on read.
createHull leaves a Skip hull's id at zero, since only writeSurface can know the offset it
holds, and marks its faces and every edge of them virtual.
interface Face {
material: number // material index, 7 bits
virtual: boolean // face belongs to a subtree-bounding hull
pierce: number // the face found by casting a ray along the inverted normal
points: TriangleIndices // point indices into the part's shared point list
opposites: TriangleIndices // flat index (face × 3 + edge) of each opposing half-edge
virtualEdges: TriangleFlags // per-edge counterpart of `virtual`; set when hull type is 5
}Fixed size: 16 bytes. The leading uint32 packs virtual (bit 31), material (bits 24–30),
pierce (bits 12–23), and the face's own tri_index (bits 0–11) — faces are therefore stored out
of order and placed into faces[index] on read.
IVP declares material_index and is_virtual as adjacent bitfields, so this module splits them
rather than exposing one byte.
Each of the three edges is a uint16 point index followed by a uint16 holding is_virtual in bit
15 and a signed 15-bit opposite_index in the rest.
opposite_index is a delta in 4-byte slots, and a triangle occupies four slots — a header word
plus three edges — so edge v of face f lives at slot 4f + v + 1. opposites exposes this as the
flat edge index 3f + v instead. The two codecs convert through a matched pair of helpers,
toEdgeIndex and toSlot, which are exact inverses.
Triangles are three indices into the point list, wound counter-clockwise seen from outside, so the
outward normal is (b - a) × (c - a). That winding is not a convention this module picked: shooting a
ray the other way matches nothing in the corpus, and it is what RENDERER.md records for
positions taken verbatim.
createFaces refuses geometry the format cannot hold rather than writing something a reader will
throw on: an open surface, where a half-edge has nothing running the other way; a half-edge two
triangles both wind; a degenerate triangle; and a point count Euler's V = 2 + F / 2 does not allow,
which is what the ledge size the writer encodes assumes.
opposites is fully determined by the triangles. pierce is not. IVP's insert_pierce_info
pairs each face with the one whose normal points most nearly the other way, walking its own triangle
list in order and skipping a face that already has a partner — but not skipping one as a candidate,
which is why the result is not an involution and neither is retail's. The order it walked is the
builder's, not the order the compact ledge stores, so every tie it broke is lost.
interface Point extends Vector3 {
clientData: number
}Fixed size: 16 bytes — float32 x/y/z, then the int32 clientData.
IVP_Compact_Poly_Point derives from IVP_U_Float_Hesse, so the coordinates come first and the
trailing word is the plane's hesse_val slot, reused as client_data.
Points are shared across all the hulls of a part — the builder re-indexes each ledge into one common
array — so faces reference them by index into Surface.points.
clientData is the id of the hull the point belongs to. MAXLancer names the field
SurfacePoint.hullID, and its GetPointIndex position hullID reuses an existing point only when the
hull id matches as well as the position — so the array is partitioned by hull rather than shared
across hulls in the way "shared" suggests. Measured: every point of 9,085 of the 9,111 terminal
hulls carries that hull's own id, 26 hulls match partially, and none match not at all. The 25% of
points whose clientData is zero are the points of the 2,525 id-zero hulls.
Nothing in this module reads it — faces index points directly and createPart carries through
whatever a caller supplies — but it means two hulls never share a point, which a reader building
per-hull vertex buffers can rely on.
createPart(id, hulls, options?) is the construction entry point. HullGeometry is one convex hull
as a caller has it — an id, its own points, and triangles indexing them. Whether two hulls share
a point is not the caller's problem: createPart folds them into the one list a surface part holds
and re-indexes the triangles, which is what IVP's builder does.
| Option | Default | Effect |
|---|---|---|
fixed |
true |
false writes the !fxd chunk |
hardpoints |
[] |
Written as hpid only when non-empty |
bounds |
'box' |
What to hang on an inner node — see below |
| mass properties | derived | Any of the four in MassProperties overrides what is derived |
A box, from nothing:
import { createBox, createPart, writeSurfaceLibrary } from '@treewyrm/freelancer/surface'
import { getResourceId } from '@treewyrm/freelancer'
const id = getResourceId('Root')
const extent = { minimum: { x: -2, y: -1, z: -6 }, maximum: { x: 2, y: 1, z: 6 } }
const part = createPart(id, [{ id, ...createBox(extent) }])
const bytes = writeSurfaceLibrary([part])Several hulls, which is what a compound model wants — one per collision-enabled part, each keyed by the CRC32 of the model part name:
const part = createPart(getResourceId('Root'), [
{ id: getResourceId('Root'), ...createBox(hull) },
{ id: getResourceId('wing_lt'), points, triangles },
], { hardpoints: [getResourceId('HpWeapon01')] })What happens, in order:
- Every hull's points are merged into the part's shared list and its triangles re-indexed onto it.
createFacesderives each face's half-edge adjacency and pierce index, and refuses geometry the format cannot hold.createNodegives each hull its bounding sphere and quantized box.createHierarchyfolds the leaves into a full binary tree, tightest pair first.- Each inner node gets the box it bounds as a
Skiphull, unlessbounds: 'none'. getMassPropertiesderivesmassCenter,radius,surfaceDeviationandrotationInertia.- The extent comes off the collision geometry, taken before step 5 added anything.
Retail always puts a hull on the root: a terminal one when the part is a single convex shape,
otherwise a Skip hull bounding everything below, carrying real convex geometry. bounds: 'box'
hangs the box the node already bounds on it instead — a valid convex bound, eight more points and
twelve more faces per inner node, and the same shape retail has.
generateConvexHull is not the missing piece here, now that it exists. What an inner node has to
contain is not its subtree's geometry but its children's quantized boxes, and quantization only
ever steps outward — so a hull over the points below would be the wrong shape, smaller than the thing
it is supposed to bound. There is no bounds: 'hull' for that reason, not for want of an algorithm.
The box used is the union of the children's boxes before it was quantized, which is exactly the box the node's sphere circumscribes. Taking the quantized box back would put its corners outside that sphere, and every retail file keeps what a node holds inside it.
bounds: 'none' leaves inner nodes bare. IVP emits that too — build_ledgetree writes
offset_compact_ledge = 0 whenever no bounding ledge was built — and this reader descends through a
hull-less node either way. It has not been observed in game.
None of it is measurable from the bytes:
- Convexity of geometry handed straight to
createPart. Nothing there checks it, and IVP's whole layout assumes it —V = 2 + F / 2is enforced, which a non-convex closed mesh will usually fail, but not always.createHullGeometryis the way not to have the problem. - Decimation. How many faces a collision hull should have is a budget, not a fact.
- Splitting a concave shape into convex hulls. Retail parts carry up to hundreds of them.
Point.clientData. The owning hull's id — see Point.createPartcarries through whatever a caller puts on its input points and otherwise writes zero, so a caller that wants retail's convention sets it to the hull id itself.
A part built this way is a fixed point: write it, read it back, write it again, and the bytes match.
createHullGeometry(id, points, options?) turns a point cloud into one HullGeometry, which is the
whole distance from a mesh to something createPart takes:
const part = createPart(getResourceId('Root'), [
createHullGeometry(getResourceId('Root'), hullPoints),
createHullGeometry(getResourceId('wing_lt'), wingPoints),
])The algorithm itself is generateConvexHull, on ./math rather than here — it knows
nothing about Freelancer, and a hull is a fact about a point set rather than a decision about a file.
It is an incremental QuickHull ported from MAXLancer's GenerateConvexHullQH, which extends the
stock algorithm in the two places boxy, CAD-like geometry breaks it:
- Coplanar tie-clusters. When three or more points tie for farthest from a face's plane, stock QuickHull takes whichever the index order offers. A cube whose faces carry any interior detail ties constantly, so that choice is not a tie-break but a coin flip. A nested 2D QuickHull over the cluster settles which of them is actually on the boundary.
- The tolerance. It is not a constant. The extreme-point search derives one float32 ULP at the
data's own magnitude — its distance from the origin, not its size — which is the right scale
because a
.surstores points as float32, so two points closer than that are the same point once written. A fixed tolerance either swallows a small part or fails to close a distant one.
What comes back holds only the points on the hull. Coordinate duplicates go first — a mesh
repeats a position once per seam meeting there, so a cube arrives as 24 points and three coincident
points in one tied cluster leave the 2D pass with nothing to build on. Interior points, and points
within tolerance of one already taken, go during the loop. ConvexHull.indices says where each
survivor came from, for carrying attributes across.
That culling is not tidiness. createPart folds every point of a HullGeometry into the part's
shared list, so an uncompacted hull writes a mesh's whole interior into surf, in a format where the
point count is the size driver.
| Option | Default | Effect |
|---|---|---|
maxPoints |
2050 |
Stop once the hull reaches this many points |
epsilon |
derived | Only seeds the extreme search; the derived tolerance replaces it |
clientData |
id |
Written onto every point — retail's convention, see Point |
maxPoints defaults to what a hull can address, not to the size of the cloud. A hull's face
index is twelve bits, so 4,096 faces, and Euler's V = 2 + F / 2 makes that 2,050 points; past it
writeHull masks the index and emits a file that reads back as something else. Raising the budget is
a way to produce one. Lowering it is decimation, and a crude form — the budget stops the hull early
rather than choosing what detail to lose, so the result is the hull of the points taken so far and
geometry outside it is geometry the collision shape does not cover.
generateConvexHull throws a RangeError rather than returning a hull it does not believe in: on
fewer than four distinct points, on a coordinate that is not finite, and on a point set with no
tetrahedron in it — every point on one plane, one line or one spot. The last is the case a caller
meets in practice, and it is why 3,843 of retail's own hulls cannot be rebuilt at all: they hold
three points or fewer.
Neither is a porting slip; both are recorded because the next reader will check.
GetFacePoints compares a negative distance against a non-negative running maximum, so every point
below the plane passes the test and it keeps the last one in index order rather than the farthest.
This compares magnitudes.
More consequentially, the original decides which faces a new point can see by walking activeFaces
— the faces that still have a candidate above them. A face drops out of that set once nothing
remains above it, which says nothing about where a later point will land. Skipping it there leaves
the new point sitting outside a face that stays, and that dent compounds: on the corpus it put 68 of
5,234 rebuilt hulls visibly out of convexity, one of them by 192% of its own size. Visibility is
tested here against every face still standing, and against the plane directly rather than through the
tolerance-widened above set, which would let an all-but-coplanar face cut the visible region in two.
| Count | |
|---|---|
.sur files |
798, of which 781 the reader accepts |
| — predating the container | 17 |
| Parts | 1,365 |
| Terminal (type-4) hulls | 9,111 |
| Subtree-bounding (type-5) hulls | 415, averaging 69 faces |
| Faces | 177,824 — 149,180 terminal, 28,644 virtual |
| Points | 79,596 |
nodeCount == 2 × terminalHulls − 1 holds for all 1,365 parts, size_div_16 for all 9,526 hulls, and
the surf chunk's two size fields agree in all 1,365. Surface.padding is zero in every file.
Face.material is 0 for all 177,824 faces; virtual is true for all 28,644 faces of type-5 hulls and
false for all 149,180 of type-4, and the three per-edge is_virtual flags always agree with it.
Point.clientData is the owning hull's id: it agrees on every point of 9,085 of the 9,111
terminal hulls, partially on 26, and on none of none. The 25% of points that are zero belong to the
2,525 hulls whose own id is zero.
Terminal hull ids split 5,314 naming a model part · 1,272 listed in their part's hpid · 2,525
zero, and surface parts match a model 549 by the id-zero rule · 747 by part name · 12 by
neither.
The extent encloses every point of its part in 1,134 of 1,365; every point a part holds is indexed by some face, in all 1,365.
17 files carry no signature at all. They open with the 24-byte bounding box, then a length-prefixed
part name where the modern format puts a part CRC, then the chunk list — whose tags include
ledg, which vers files never use. li_battleship.sur and the freight train are the notable ones.
Nothing past the bounding box is shared with the version 2 layout, so readSurfaceLibrary rejects them
with Invalid SUR header rather than misreading them. This module does not support the older
format.
| Derived value | Reproduces retail |
|---|---|
Face.opposites |
177,824 of 177,824 — fully determined |
Face.pierce |
151,761 of 177,824 |
Surface.massCenter |
all 1,365 parts, to within 1e-4 × radius |
Surface.radius |
all 1,365 |
Surface.surfaceDeviation |
1,349 of 1,365 |
Surface.rotationInertia |
2,067 of 4,095 components |
| — on parts enclosing no volume | 84 of 84, uniform across the axes |
| Leaf node centre and radius | all 9,111, to within 1e-4 relative |
| Leaf node quantized box, all three bytes | 9,110 of 9,111 |
The pierce residue is ties on symmetric hulls, where two faces are equally opposite and the file
records the one this module cannot know it picked. The 16 surfaceDeviation misses sit on a step and
the truncation falls the other side of it. rotationInertia is the one real disagreement — see
TODO.
Every terminal hull is a convex polyhedron given as exactly its own vertices, which makes rebuilding one from those vertices a fixed point the generator either reaches or visibly misses. They are also the adversarial case: already decimated, so near-coplanar faces come in quantity.
| Count | |
|---|---|
| Terminal hulls | 9,111 |
| — of three points or fewer, so holding no tetrahedron | 3,843 |
| — flat: every point on one plane | 34 |
| — rebuilt | 5,234 |
| Rebuilt hulls that are convex | 5,234 of 5,234 |
| Rebuilt hulls containing every point they were built from | 5,234 of 5,234 |
| Rebuilt hulls with retail's exact vertex count | 4,925 |
| — with fewer | 309 |
| — with more | 0 |
The 309 are retail keeping a vertex that lies on the plane of a face it does not corner; this drops it. None is ever larger, which would mean a point invented or an interior one kept.
writeSurfaceLibrary reproduces the IVP layout but not Freelancer's exact ordering: IVP's builder
emits terminal ledges before subtree-bounding ones, whereas this writer emits them in tree order.
Re-encoding is therefore not byte-identical to the original file, but it is a fixed point —
writing a decoded library and reading it back yields the same structure, and encoding that again gives
identical bytes. Both properties hold across all 781 files the reader accepts; 574 of them re-encode
byte for byte regardless.
The one value that does not survive verbatim is a type-5 hull's id, which is a derived offset and is
reassigned on write.
The field is read. A part blown off a model becomes debris and tumbles by it — observed in game.
MAXLancer carries the same slot as drag and says the question is open; it is not.
getMassProperties implements IVP's IVP_Rot_Inertia_Solver as written, and it is right where it can
be checked. Every one of the 84 parts that take the degenerate fallback reproduces exactly. On hulls
that are still exact boxes — crate_blue.sur and the rest of the twelve-face crates — it reproduces
the shipped figure to better than a part in a thousand. Over the whole corpus it does not: 2,067 of
4,095 components agree within 0.1%, another ~700 are within a few percent, and roughly a quarter are
off by more than 25%. pod_drab.sur computes 72.27 where the file says 122.50.
The disagreement tracks decimation. crate_grey.sur's hull has ten faces where a box has
twelve, and it misses by 0.5%; pod_drab.sur's has 62 and misses by 41%. massCenter and radius
agree on every one of those same parts, so the geometry is being read correctly. The reading is that
Freelancer measured the inertia on the art mesh and then simplified the collision hull, and the file
kept the earlier number. Nothing in the file can recover it, because the mesh it was measured from
is not in the file.
So the open question is not whether to derive it but how far off is too far. What is known:
- The derived value is much the closer of the two candidates. Against retail it wins on 3,590 of 4,095 components, and is within 0.1% on 2,067 where MAXLancer's rule manages 4.
- MAXLancer generates the field as
0.2 × radius²on all three axes, jittered by ±0.1% so they differ, and mods built with it work. That rule is within 25% of retail on barely a quarter of components — retail's own mean ratio toradius²is 0.137, not 0.2 — so the game tolerates a figure that is wrong by tens of percent, which bounds how much the residual gap here can matter. - The jitter is not required by anything measurable: retail ships 92 parts whose three components are identical, and this module reproduces 84 of them exactly. A builder here stays deterministic.
The experiment that would sharpen it: take a retail .sur whose stored inertia is far from the
derived one — the pod_* debris are the clearest, being loose objects that tumble — rewrite it with
the derived value and shoot the object, then again with 0.2 × radius². The difference between those
two spins is the whole size of the question.
Until then createPart derives it, because a number from the right algorithm on a simplified mesh
beats a constant that is wrong by 46% at the median. Pass the art mesh's figure through PartOptions
when it is at hand, which is why the override exists.