V3 API Reference — Custom Column Extraction
The V3 surface is for the cases where GetFileSystemRecords_V2
is the wrong tool: huge datasets where you can't materialise every record at
once, or queries where you only need a few columns out of the rich record
schema and don't want to pay for the rest.
V3 lets you:
- Pick the columns you want by name (
EntryName,LogicalSize,Modified, …) - Window the read with a
StartRecord/RequestedCountpair, so you can page through a million-file image at constant memory. - Get columnar output — one contiguous array per column rather than an array of structs — which is friendlier to CSV export, Arrow / Parquet pipelines, and most analytical workloads.
Exports covered on this page: GetAvailableProperties,
CreatePropertySpecsFromJSON, GetFileSystemRecordsCustom_V2,
GetFileSystemRecordsCustom_V3, FreePropertySpecArray.
- When to choose V3 over V2
- The four-step workflow
GetAvailablePropertiesCreatePropertySpecsFromJSONGetFileSystemRecordsCustom_V3GetFileSystemRecordsCustom_V2(no windowing)FreePropertySpecArray- End-to-end C# example
- Reading column data by type
- Common patterns
When to choose V3 over V2
| Question | If yes, use… |
|---|---|
| Filesystem has more than ~500K records and you need every record? | V3 with pagination |
You only need a handful of columns (e.g. EntryName, LogicalSize, Modified)? |
V3 — skip the cost of materialising the rest |
| You're streaming records into a CSV / Parquet / database writer? | V3 — column-major output maps directly |
| You need every column on a small-to-medium image? | V2 (GetFileSystemRecords_V2) — convenience wins |
| You're prototyping or exploring an unknown image? | V2 — fewer moving parts |
The V2 path materialises the entire TFileRecord array in one allocation
sized roughly RecordCount × ~80 bytes + sum(filename lengths) + sum(children list bytes).
For a 10 M-record image that runs into the gigabytes; on 32-bit-clean
allocators it can hit the 4 GB cap and return RESULT_BUFFER_OVERFLOW.
V3 windowing avoids that ceiling entirely.
The four-step workflow
1. GetAvailableProperties → JSON list of every column the DLL knows
2. CreatePropertySpecsFromJSON → build a TPropertySpec[] from a name list
3. GetFileSystemRecordsCustom_V3 (loop with StartRecord += page)
→ DLL writes columnar data into each spec
4. FreePropertySpecArray → release the whole graph in one call
Step 1 is optional once you know the column names you want. Steps 2-4 are the per-job sequence.
For tiny / one-shot workloads where you don't need pagination,
GetFileSystemRecordsCustom_V2 is V3 minus the windowing — same spec
shape, same cleanup. See below.
GetAvailableProperties
Returns a JSON array of every property the V3 path can extract. Use this to discover available column names without hard-coding.
Pascal signature:
function GetAvailableProperties(out Buffer: PByte;
out BufferSize: uint32): int32; cdecl;
C# binding:
[DllImport("FEX.Core.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int GetAvailableProperties(out IntPtr buffer, out uint bufferSize);
Returns: RESULT_OK, RESULT_BUFFER_OVERFLOW.
Buffer ownership: caller frees buffer with FreeAllocatedBuffer.
JSON shape:
[
{"name": "EntryName", "type": "String", "typeKind": "tkUString"},
{"name": "LogicalSize", "type": "Int64", "typeKind": "tkInt64"},
{"name": "Modified", "type": "DateTime", "typeKind": "tkFloat"},
{"name": "Created", "type": "DateTime", "typeKind": "tkFloat"},
{"name": "Status", "type": "UInt64", "typeKind": "tkInt64"},
...
]
The type column maps onto PROPERTY_TYPE_* constants — see the property
type table in types-and-constants.md.
This is also the starting point for the JSON you'll hand to
CreatePropertySpecsFromJSON.
CreatePropertySpecsFromJSON
Parses a JSON spec into a TPropertySpec array suitable for V3 extraction.
Pascal signature:
function CreatePropertySpecsFromJSON(JSONString: PUTF8Char;
out PropertySpecs: Pointer;
out PropertyCount: int32): int32; cdecl;
C# binding:
[DllImport("FEX.Core.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern int CreatePropertySpecsFromJSON(
[MarshalAs(UnmanagedType.LPStr)] string jsonString,
out IntPtr propertySpecs,
out int propertyCount);
Parameters:
| Name | Type | Dir | Purpose |
|---|---|---|---|
jsonString |
UTF-8 JSON | in | Array of property specs (see below). |
propertySpecs |
IntPtr |
out | DLL-allocated TPropertySpec[propertyCount]. Free with FreePropertySpecArray. |
propertyCount |
int32 |
out | Number of specs parsed. |
Returns: RESULT_OK, RESULT_INVALID_JSON, RESULT_OUT_OF_MEMORY.
JSON spec shape:
[
{"name": "EntryName"},
{"name": "LogicalSize"},
{"name": "Modified", "fieldName": "lastModified"}
]
Each entry needs at least name (a property name from
GetAvailableProperties). Optional fields:
fieldName— output alias if you want to rename the column in your consumer (the DLL still usesnameto look up the source).type— force aPROPERTY_TYPE_*value if you want a different representation than the auto-detected one (rarely needed).transform— reserved for future transformation hints. Leave empty.
Limits: propertyCount is capped at 100. Need more columns? Run
multiple V3 passes against the same dataset.
GetFileSystemRecordsCustom_V3
The windowed extractor. Each call writes a chunk of the dataset into the spec array and reports the dataset's full size — so the caller knows when to stop paging.
Pascal signature:
function GetFileSystemRecordsCustom_V3(ImageID: int32;
PropertySpecs: Pointer;
PropertyCount: int32;
StartRecord: int32;
RequestedCount: int32;
out TotalRecords: uint32): int32; cdecl;
C# binding:
[DllImport("FEX.Core.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int GetFileSystemRecordsCustom_V3(
int imageId,
IntPtr propertySpecs,
int propertyCount,
int startRecord,
int requestedCount,
out uint totalRecords);
Parameters:
| Name | Type | Dir | Purpose |
|---|---|---|---|
imageId |
int32 |
in | Image-session ID. ReadFileSystem must have been called first. |
propertySpecs |
IntPtr |
in/out | Spec array from CreatePropertySpecsFromJSON. The DLL writes column data into each spec's FDataPtr. |
propertyCount |
int32 |
in | Length of the spec array (max 100). |
startRecord |
int32 |
in | Zero-based start offset in the dataset. Pass -1 to mean 0 (legacy convenience). |
requestedCount |
int32 |
in | Number of records to extract. Pass -1 to mean "all remaining from startRecord". |
totalRecords |
uint32 |
out | Full dataset count, independent of windowing — useful for paging UI ("page 4 of 73"). |
Returns: RESULT_OK, RESULT_IMAGEIDNOTFOUND (-5),
RESULT_INVALID_PARAMETERS (-18) — passed when propertyCount <= 0,
propertyCount > 100, or startRecord ≥ totalRecords.
Output layout:
After a successful call, each spec has been populated:
| Field | Meaning |
|---|---|
FDataPtr |
Pointer to a contiguous array of FNumberValue values. |
FValueSize |
Element size in bytes (e.g. 8 for Int64, 8 for pointer-to-string). |
FNumberValue |
Number of elements written — equal to the lesser of RequestedCount and TotalRecords - StartRecord. |
The columns are aligned by row: column[0].DataPtr[0] and
column[1].DataPtr[0] describe the same record. Reading values out is
type-specific — see Reading column data by type.
Buffer ownership: the spec array and every FDataPtr it points to are
owned by the DLL. Free the whole graph at the end with
FreePropertySpecArray(specs, count). Never call FreeAllocatedBuffer
on a column pointer.
A second call to GetFileSystemRecordsCustom_V3 against the same spec
array re-uses it: the DLL frees the previous page's columns internally
before writing the new ones. Don't try to keep two pages alive
simultaneously through one spec array — copy the values you need into
managed types first.
GetFileSystemRecordsCustom_V2 (no windowing)
Same shape as V3 minus the start/requested parameters. Returns every record in one shot. Use this when you know the dataset is small enough.
Pascal signature:
function GetFileSystemRecordsCustom_V2(ImageID: int32;
PropertySpecs: Pointer;
PropertyCount: int32;
out RecordCount: uint32): int32; cdecl;
C# binding:
[DllImport("FEX.Core.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int GetFileSystemRecordsCustom_V2(
int imageId,
IntPtr propertySpecs,
int propertyCount,
out uint recordCount);
Returns: identical to V3 minus RESULT_INVALID_PARAMETERS for the
windowing-specific cases.
Buffer ownership: identical to V3 — free with FreePropertySpecArray.
V3 is preferred for new code; the V2 column variant exists mainly for small workloads where the extra arguments would just be noise.
FreePropertySpecArray
Releases the entire spec graph: every FDataPtr, every string slot inside
string columns, every children list inside ARRAYUINT32 columns, and
finally the spec array itself.
Pascal signature:
function FreePropertySpecArray(PropertySpecs: Pointer;
PropertyCount: int32): int32; cdecl;
C# binding:
[DllImport("FEX.Core.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int FreePropertySpecArray(IntPtr propertySpecs, int propertyCount);
Returns: RESULT_OK, RESULT_ERROR (only if the underlying free
raised an exception).
Pair every CreatePropertySpecsFromJSON with exactly one
FreePropertySpecArray, regardless of how many V3 calls happened in
between.
End-to-end C# example
The example below paginates a million-record dataset, extracting just three columns into CSV. The pattern scales to any column count and any page size.
using System.Globalization;
using System.Runtime.InteropServices;
const int PageSize = 100_000;
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Ansi)]
public struct PropertySpec
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string PropertyName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string FieldName;
public int PropertyID;
public int PropertyType;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string Transform;
public IntPtr DataPtr;
public int ValueSize;
public int NumberValue;
}
public static void ExportFileNamesAndSizesToCsv(int imageId, string csvPath)
{
NativeMethods.ReadFileSystem(imageId, out _);
string specJson = """
[
{"name": "EntryName"},
{"name": "LogicalSize"},
{"name": "Modified"}
]
""";
IntPtr specs = IntPtr.Zero;
int count = 0;
try
{
int rc = NativeMethods.CreatePropertySpecsFromJSON(specJson, out specs, out count);
if (rc != NativeMethods.RESULT_OK)
throw new FexCoreException("CreatePropertySpecsFromJSON", rc);
using var writer = new StreamWriter(csvPath);
writer.WriteLine("name,logicalSize,modifiedUtc");
int stride = Marshal.SizeOf<PropertySpec>();
int start = 0;
uint totalRecords = 0;
while (true)
{
rc = NativeMethods.GetFileSystemRecordsCustom_V3(
imageId, specs, count, start, PageSize, out totalRecords);
if (rc != NativeMethods.RESULT_OK)
throw new FexCoreException("GetFileSystemRecordsCustom_V3", rc);
// Re-read each spec to get this page's column pointers
var specArray = new PropertySpec[count];
IntPtr cur = specs;
for (int i = 0; i < count; i++)
{
specArray[i] = Marshal.PtrToStructure<PropertySpec>(cur);
cur = IntPtr.Add(cur, stride);
}
int rowsThisPage = specArray[0].NumberValue;
if (rowsThisPage <= 0) break;
// Column 0: EntryName (string column — IntPtr per element)
// Column 1: LogicalSize (int64)
// Column 2: Modified (int64 FILETIME)
for (int row = 0; row < rowsThisPage; row++)
{
IntPtr namePtr = Marshal.ReadIntPtr(
specArray[0].DataPtr, row * specArray[0].ValueSize);
long size = Marshal.ReadInt64(
specArray[1].DataPtr, row * specArray[1].ValueSize);
long modFt = Marshal.ReadInt64(
specArray[2].DataPtr, row * specArray[2].ValueSize);
string name = namePtr != IntPtr.Zero
? Marshal.PtrToStringUTF8(namePtr) ?? ""
: "";
string modIso = modFt > 0
? DateTime.FromFileTimeUtc(modFt).ToString("o", CultureInfo.InvariantCulture)
: "";
writer.WriteLine($"{Csv(name)},{size},{modIso}");
}
start += rowsThisPage;
if ((uint)start >= totalRecords) break;
}
}
finally
{
if (specs != IntPtr.Zero)
NativeMethods.FreePropertySpecArray(specs, count);
}
}
private static string Csv(string s) =>
s.Contains(',') || s.Contains('"') || s.Contains('\n')
? "\"" + s.Replace("\"", "\"\"") + "\""
: s;
Key points to notice:
- Re-read the spec array each iteration — the DLL updates
DataPtr,ValueSize, andNumberValuein place every call. - Use
Marshal.ReadIntPtr/ReadInt64/ReadInt32to dereference column pointers;Marshal.PtrToStructureis overkill for primitives. - One cleanup, in the outer
finally. Don't try to free anything between pages. - Resolve every UTF-8 string before the next page or the cleanup — the column's pointer is in the DLL-owned region.
Reading column data by type
FValueSize tells you how many bytes per slot, but you still need to know
the in-memory shape. The mapping below covers every type the DLL emits.
PROPERTY_TYPE_* |
FValueSize (typical) |
Slot contains | Read with |
|---|---|---|---|
STRING (1) |
8 | IntPtr to UTF-8 null-terminated string |
Marshal.ReadIntPtr(col, i*size) → Marshal.PtrToStringUTF8 |
INTEGER (2) / INT32 (11) |
4 | int32 |
Marshal.ReadInt32(col, i*size) |
INT64 (3) |
8 | int64 |
Marshal.ReadInt64(col, i*size) |
BOOLEAN (4) |
1 | byte (0 or 1) |
Marshal.ReadByte(col, i*size) != 0 |
DATETIME (5) |
8 | int64 Windows FILETIME |
Marshal.ReadInt64 → DateTime.FromFileTimeUtc (skip if <= 0) |
GUID (6) |
16 | 16 raw bytes | read 16 bytes → new Guid(bytes) |
BYTES (7) |
8 | IntPtr to a byte buffer (length is format-specific) |
inspect via Marshal.Copy once you know the length convention |
UINT16 (8) / INT16 (9) |
2 | uint16 / int16 |
Marshal.ReadInt16 |
UINT32 (10) |
4 | uint32 |
Marshal.ReadInt32 then cast |
UINT64 (12) |
8 | uint64 |
Marshal.ReadInt64 then cast |
ARRAYUINT32 (13) |
8 | IntPtr to a TChildrenList (length-prefixed) |
read the first int32 for count, then count × int32 for IDs |
Always check FNumberValue for the live row count of this page — it can
be less than RequestedCount near the end of the dataset.
Common patterns
CSV export, three columns, paginated
See the end-to-end example above.
Just the file count
NativeMethods.ReadFileSystem(imageId, out _);
IntPtr specs = IntPtr.Zero; int count = 0;
try
{
NativeMethods.CreatePropertySpecsFromJSON(@"[{""name"":""EntryName""}]", out specs, out count);
NativeMethods.GetFileSystemRecordsCustom_V3(
imageId, specs, count,
startRecord: 0, requestedCount: 0, // request zero rows…
out uint total); // …but the total is still reported
Console.WriteLine($"{total:N0} records in image");
}
finally
{
if (specs != IntPtr.Zero) NativeMethods.FreePropertySpecArray(specs, count);
}
A RequestedCount of zero is a valid query — the DLL fills in TotalRecords
without populating any column. Cheap way to size up an unfamiliar image.
Filtering on the C# side
V3 doesn't push filter predicates into the DLL. Pull the columns you need, filter in your loop:
for (int row = 0; row < rowsThisPage; row++)
{
long status = (long)Marshal.ReadInt64(specArray[3].DataPtr, row * specArray[3].ValueSize);
if ((status & (long)StatusFlags.FILESTATUS_DELETED) == 0) continue;
// ... handle deleted record ...
}
If you find yourself iterating millions of rows just to filter most of them out, consider whether your column set should include the filter predicate — at that point the cost is cache-friendly sequential access rather than per-record DLL calls.
Reusing one spec array across multiple images
Spec arrays are per-process and not bound to an image — the same
(specs, count) pair can be passed to V3 calls against any open
ImageID. Allocate once, reuse, free when you're done with the whole
batch.