Phase 18: preserve portable project media paths and verify format migrations

This commit is contained in:
Johannes Findeisen 2026-10-27 06:31:59 +01:00
commit 1369e4d463
15 changed files with 329 additions and 13 deletions

View file

@ -336,3 +336,11 @@ test-editing: $(BUILD_DIR)/test_editing
./$(BUILD_DIR)/test_editing
test: test-editing
-include $(BUILD_DIR)/tests/unit/test_editing.d
$(BUILD_DIR)/test_portability: $(BUILD_DIR)/tests/integration/test_portability.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm -ldl
.PHONY: test-portability
test-portability: $(BUILD_DIR)/test_portability
./$(BUILD_DIR)/test_portability
test: test-portability
-include $(BUILD_DIR)/tests/integration/test_portability.d

View file

@ -1,4 +1,4 @@
# Project format v5 (reads v1/v2/v3/v4)
# Project format v7 (reads v1–v6)
Phases 5–15 use a bounded text format implemented in C99 without a parser library.
The extension is `.xtc`. File operations run exclusively on the control thread.
@ -12,7 +12,7 @@ Fields use ASCII spaces. Integers are unsigned decimal, without signs.
Counts determine the number of subsequent records:
```text
XTC_PROJECT 5
XTC_PROJECT 7
APP 0 1 0
TEMPO 120000
IDS 1 1
@ -82,8 +82,8 @@ node types 1..4, migrating their clips to empty asset paths. Version 2 accepts
node types 1..5 and ASSET records. Both migrate to MIDI=0 and no notes.
Version 3 accepts instrument nodes and MIDI/NOTES records. Versions 1–3 migrate
all tracks to unity gain, centered pan, mute/solo off and no automation.
Saving always writes v5. Unknown versions are rejected without replacing the destination; older
applications cannot read v5. Migration and maximum-path roundtrips are tested.
Saving always writes v7. Unknown versions are rejected without replacing the destination; older
applications cannot read v7. Migration and maximum-path roundtrips are tested.
## Ownership and failures
@ -148,3 +148,43 @@ port/tick pairs, nonfinite values, unknown control ports and ticks beyond the
project limit are rejected. Loaded plugin tempo comes from TEMPO. Range and
discrete-mode compatibility are checked on control-thread compilation. Versions
1–5 migrate with empty plugin lanes. See CONTROL.md for timing/ordering.
## Phase 18: version 7 and project folders
Current writer emits v7; readers migrate v1–v6. Record ordering and field bounds
are otherwise unchanged from v6. v7 ASSET may contain a relative path, interpreted
against the actual containing directory of the named project file (resolved on
the control thread), never the launch working directory. After loading, all core
asset references are absolute, including missing media. Media inside the save
folder are written relative; references outside remain absolute. Save As resolves
against its new folder and preserves media identity rather than rebasing it by
accident. Copy/move a project folder together with its referenced media to retain
portability. Saving does not copy assets: import/record into that folder first.
Plugin binaries remain trusted absolute module references, not bundled resources.
Empty assets still use `-`. Paths exclude embedded NUL/control bytes and must fit
511 bytes after resolution; overly long resolved references fail transactionally.
Relative references can name outside the folder using ordinary POSIX semantics;
this is not a sandbox. Symlinks follow filesystem behavior, so portability requires
moving their targets too. Old formats continue to require absolute media paths.
No unknown version/node/record is silently discarded; unsupported versions/types
or malformed/trailing data reject the candidate and retain the current project.
Parsing never opens media/plugins or constructs an audio engine.
Save validates the complete model, writes/fsyncs a same-folder temporary, closes
it, atomically renames it, then fsyncs the containing directory. Failures before
rename retain the old destination. A directory sync failure *after* rename reports
I/O error but the new complete file may already be present: inspect/reopen it;
there is no rollback across that durability boundary. Filesystem/hardware power
loss guarantees still depend on the underlying storage. Recording destinations
have their separate no-clobber policy; ordinary project Save intentionally replaces
the selected document. In-memory models and the existing destination are preserved
on load failure. No automatic unknown-field stripping or media deletion occurs.
`make test-portability test test-plugins test-recovery test-recording` covers checked-in
v1–v6 fixtures (including missing v5/v6 plugins), v7 exact roundtrips, moved-folder
playback from a different cwd, missing media retention, unknown records, every
truncation and deterministic byte mutations, malformed plugin metadata, interrupted
writers and write-limit failures. ASan/UBSan/static analysis additionally audit the
bounded parser. This is regression/fault-injection coverage, not exhaustive fuzzing
or a claim of power-cut qualification.

View file

@ -385,6 +385,14 @@ screen-reader/accessibility qualification remain explicit limits.
- Missing media and plugins are reported clearly.
- Save/load round trips and format migrations have automated tests.
Implemented: v7 folder-relative media persistence with absolute runtime references,
transactional resolution independent of launch cwd, Save As identity preservation,
and parent-directory fsync after atomic publication. Checked-in v1–v6 migrations
(including missing plugins), moved-folder exact playback, missing-media retention,
truncation/mutation and unknown-record/error tests pass. See PROJECT_FORMAT.md.
No media is copied/deleted automatically; absolute external/plugin dependencies,
path bounds, post-rename sync errors and physical power-loss limits are explicit.
## Phase 19 — Performance, Scalability, and Stress Testing
**Goal:** Measure performance and establish predictable behavior as projects grow.

View file

@ -208,7 +208,9 @@ xtc_result xtc_project_sample_to_tick(const xtc_project *project, uint64_t sampl
uint64_t *out_tick);
/* Control-thread I/O. Versioned, bounded format; see docs/PROJECT_FORMAT.md.
* Load is transactional. Save fsyncs a same-directory temporary and renames it;
* failures before rename preserve the previous destination. No asset I/O. */
* failures before rename preserve the previous destination. No asset I/O. v7 saves local media paths relative to the project folder; load
* resolves them to absolute references. Post-rename directory-sync failure can
* report IO with the complete new document already published. */
/**
* @brief Saves a versioned project through a synced temporary file and atomic rename.
*

View file

@ -1,3 +1,4 @@
#define _XOPEN_SOURCE 700
#define _POSIX_C_SOURCE 200809L
#include "../project/project_internal.h"
#include "../graph/graph_internal.h"
@ -10,6 +11,7 @@
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
typedef struct {
FILE *file;
@ -184,14 +186,14 @@ static int decode_float(const char *text, float *value)
* @param project Project model; borrowed for this call.
* @return XTC_OK on success, or an error code describing the failure.
*/
static xtc_result read_project(reader *input, xtc_project *project)
static xtc_result read_project(reader *input, xtc_project *project, const char *directory)
{
uint64_t version, value, next_id, node_count, edge_count, format_version;
size_t i, j;
xtc_graph *graph = project->graph;
READ("XTC_PROJECT", 2U);
NUM(1U, version);
if (version < 1U || version > 6U)
if (version < 1U || version > 7U)
return XTC_ERR_UNSUPPORTED;
format_version = version;
READ("APP", 4U);
@ -402,6 +404,15 @@ static xtc_result read_project(reader *input, xtc_project *project)
return XTC_ERR_FORMAT;
if (ferror(input->file))
return XTC_ERR_IO;
if (format_version >= 7) for (i = 0; i < project->clip_count; ++i) {
char *asset = project->clips[i].asset_path;
if (asset[0] && asset[0] != '/') {
size_t root = strlen(directory), length = strlen(asset);
if (root+length+2 > XTC_ASSET_PATH_BYTES) return XTC_ERR_CAPACITY;
memmove(asset+root+1,asset,length+1);
memcpy(asset,directory,root); asset[root] = '/';
}
}
return xtc_project_validate(project);
}
@ -428,7 +439,7 @@ static int write_name(FILE *file, const char *name)
* @param project Project model; borrowed for this call.
* @return XTC_OK on success, or an error code describing the failure.
*/
static xtc_result write_project(FILE *file, const xtc_project *project)
static xtc_result write_project(FILE *file, const xtc_project *project, const char *directory)
{
const xtc_graph *graph = project->graph;
size_t i;
@ -437,7 +448,7 @@ static xtc_result write_project(FILE *file, const xtc_project *project)
return XTC_ERR_UNSUPPORTED;
if (sizeof(float) != sizeof(uint32_t) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128)
return XTC_ERR_UNSUPPORTED;
if (fprintf(file, "XTC_PROJECT 6\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
if (fprintf(file, "XTC_PROJECT 7\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n",
XTC_VERSION_MAJOR, XTC_VERSION_MINOR, XTC_VERSION_PATCH, project->tempo, project->next_track_id,
project->next_clip_id, graph->require_output_nodes, graph->next_id, graph->node_count,
graph->edge_count) < 0)
@ -493,7 +504,10 @@ static xtc_result write_project(FILE *file, const xtc_project *project)
if (fputs("ASSET ", file) == EOF)
return XTC_ERR_IO;
if (clip->asset_path[0]) {
if (!write_name(file, clip->asset_path))
const char *asset = clip->asset_path;
size_t root = strlen(directory);
if (!strncmp(asset,directory,root) && asset[root] == '/' && asset[root+1]) asset += root+1;
if (!write_name(file, asset))
return XTC_ERR_IO;
} else if (fputs("-\n", file) == EOF)
return XTC_ERR_IO;
@ -537,6 +551,19 @@ static size_t path_length(const char *path)
return size == 4096U ? 0U : size;
}
/* Resolve only the containing folder: media need not exist. Save replaces the
* named entry (including a symlink), so do not resolve the final component. */
static char *project_folder(const char *path)
{
char *copy = malloc(strlen(path)+2), *slash, *folder;
if (!copy) return NULL;
strcpy(copy,path); slash = strrchr(copy,'/');
if (!slash) strcpy(copy,".");
else if (slash == copy) slash[1] = 0;
else *slash = 0;
folder = realpath(copy,NULL); free(copy); return folder;
}
/**
* @brief Loads and validates a project before transactionally replacing the current state.
*
@ -548,15 +575,19 @@ xtc_result xtc_project_load(xtc_project *project, const char *path)
{
xtc_project *candidate = NULL;
reader input = {0};
char *directory;
xtc_result result;
if (project == NULL || path_length(path) == 0U)
return XTC_ERR_INVALID_ARGUMENT;
input.file = fopen(path, "rb");
if (input.file == NULL)
return XTC_ERR_IO;
directory = project_folder(path);
if (!directory) { fclose(input.file); return XTC_ERR_IO; }
result = xtc_project_create(&candidate);
if (result == XTC_OK)
result = read_project(&input, candidate);
result = read_project(&input, candidate, directory);
free(directory);
if (fclose(input.file) != 0 && result == XTC_OK)
result = XTC_ERR_IO;
if (result == XTC_OK) {
@ -578,7 +609,7 @@ xtc_result xtc_project_load(xtc_project *project, const char *path)
xtc_result xtc_project_save(const xtc_project *project, const char *path)
{
size_t length = path_length(path);
char *temporary;
char *temporary, *directory;
FILE *file;
int descriptor;
xtc_result result;
@ -604,7 +635,8 @@ xtc_result xtc_project_save(const xtc_project *project, const char *path)
free(temporary);
return XTC_ERR_IO;
}
result = write_project(file, project);
directory = project_folder(path);
result = directory ? write_project(file, project, directory) : XTC_ERR_IO;
if (result == XTC_OK && fflush(file) != 0)
result = XTC_ERR_IO;
if (result == XTC_OK && fsync(descriptor) != 0)
@ -615,6 +647,12 @@ xtc_result xtc_project_save(const xtc_project *project, const char *path)
result = XTC_ERR_IO;
if (result != XTC_OK)
(void)unlink(temporary);
if (result == XTC_OK) {
int folder = open(directory,O_RDONLY);
if (folder < 0) result = XTC_ERR_IO;
else { if (fsync(folder)) result = XTC_ERR_IO; if (close(folder)) result = XTC_ERR_IO; }
}
free(directory);
free(temporary);
return result;
}

14
tests/fixtures/project-plugin-v5.xtc vendored Normal file
View file

@ -0,0 +1,14 @@
XTC_PROJECT 5
APP 0 1 0
TEMPO 120000
IDS 0 0
GRAPH 1 1 1 0
NODE 0 8 00000000
PLUGIN 900000 0 1 1 7874635f746573745f6761696e
MODULE 2f6d697373696e672f746573742e736f
CONTROL 2 3f800000
TRACKS 0
CLIPS 0
NOTES 0 0
AUTOMATION 0
END

16
tests/fixtures/project-plugin-v6.xtc vendored Normal file
View file

@ -0,0 +1,16 @@
XTC_PROJECT 6
APP 0 1 0
TEMPO 120000
IDS 0 0
GRAPH 1 1 1 0
NODE 0 8 00000000
PLUGIN 900000 0 1 1 7874635f746573745f6761696e
MODULE 2f6d697373696e672f746573742e736f
CONTROL 2 3f800000
PLUGIN_AUTOMATION 1
PARAM 960 2 0 3f000000
TRACKS 0
CLIPS 0
NOTES 0 0
AUTOMATION 0
END

13
tests/fixtures/project-v1.xtc vendored Normal file
View file

@ -0,0 +1,13 @@
XTC_PROJECT 1
APP 0 1 0
TEMPO 120000
IDS 1 1
GRAPH 1 2 2 1
NODE 0 1 3f000000
NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 4b657973
CLIPS 1
CLIP 0 0 0 960 0 960 5665727365
END

14
tests/fixtures/project-v2.xtc vendored Normal file
View file

@ -0,0 +1,14 @@
XTC_PROJECT 2
APP 0 1 0
TEMPO 120000
IDS 1 1
GRAPH 1 2 2 1
NODE 0 1 3f000000
NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 4b657973
CLIPS 1
CLIP 0 0 0 960 0 960 5665727365
ASSET -
END

17
tests/fixtures/project-v3.xtc vendored Normal file
View file

@ -0,0 +1,17 @@
XTC_PROJECT 3
APP 0 1 0
TEMPO 120000
IDS 1 1
GRAPH 1 2 2 1
NODE 0 1 3f000000
NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 4b657973
CLIPS 1
CLIP 0 0 0 960 0 960 5665727365
ASSET -
MIDI 1
NOTES 1 1
NOTE 0 0 0 480 69 100 0
END

20
tests/fixtures/project-v4.xtc vendored Normal file
View file

@ -0,0 +1,20 @@
XTC_PROJECT 4
APP 0 1 0
TEMPO 120000
IDS 1 1
GRAPH 1 2 2 1
NODE 0 1 3f000000
NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 4b657973
MIX 3f800000 00000000 0 0
CLIPS 1
CLIP 0 0 0 960 0 960 5665727365
ASSET -
MIDI 1
NOTES 1 1
NOTE 0 0 0 480 69 100 0
AUTOMATION 1
POINT 0 0 0 3f800000
END

20
tests/fixtures/project-v5.xtc vendored Normal file
View file

@ -0,0 +1,20 @@
XTC_PROJECT 5
APP 0 1 0
TEMPO 120000
IDS 1 1
GRAPH 1 2 2 1
NODE 0 1 3f000000
NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 4b657973
MIX 3f800000 00000000 0 0
CLIPS 1
CLIP 0 0 0 960 0 960 5665727365
ASSET -
MIDI 1
NOTES 1 1
NOTE 0 0 0 480 69 100 0
AUTOMATION 1
POINT 0 0 0 3f800000
END

20
tests/fixtures/project-v6.xtc vendored Normal file
View file

@ -0,0 +1,20 @@
XTC_PROJECT 6
APP 0 1 0
TEMPO 120000
IDS 1 1
GRAPH 1 2 2 1
NODE 0 1 3f000000
NODE 1 4 00000000
EDGE 0 1
TRACKS 1
TRACK 0 4b657973
MIX 3f800000 00000000 0 0
CLIPS 1
CLIP 0 0 0 960 0 960 5665727365
ASSET -
MIDI 1
NOTES 1 1
NOTE 0 0 0 480 69 100 0
AUTOMATION 1
POINT 0 0 0 3f800000
END

View file

@ -0,0 +1,80 @@
#define _XOPEN_SOURCE 700
#include "xtc/xtc_audio.h"
#include "xtc/xtc_mixer.h"
#include "xtc/xtc_midi.h"
#include "xtc/xtc_plugin.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void)
{
char root[] = "/tmp/xtc-portable-XXXXXX", moved[512], file[512], media[512], old[512];
xtc_project *p = NULL; unsigned version; uint64_t track, clip;
float in[64], expected[64], actual[64]; size_t i;
xtc_recorder *rec = NULL; xtc_audio_plan *audio = NULL;
assert(mkdtemp(root));
assert(xtc_project_create(&p) == XTC_OK);
snprintf(file,sizeof(file),"%s/migration.xtc",root);
for (version = 1; version <= 6; ++version) {
xtc_track_info t; xtc_clip_info c; size_t notes, points;
snprintf(old,sizeof(old),"tests/fixtures/project-v%u.xtc",version);
assert(xtc_project_load(p,old) == XTC_OK);
assert(xtc_project_get_track(p,0,&t) == XTC_OK && !strcmp(t.name,"Keys") && t.gain == 1);
assert(xtc_project_get_clip(p,0,&c) == XTC_OK && c.midi == (version >= 3));
assert(xtc_project_note_count(p,&notes) == XTC_OK && notes == (size_t)(version >= 3));
assert(xtc_project_automation_count(p,&points) == XTC_OK && points == (size_t)(version >= 4));
assert(xtc_project_save(p,file) == XTC_OK && xtc_project_load(p,file) == XTC_OK);
}
for (version = 5; version <= 6; ++version) {
xtc_plugin_spec spec; xtc_plan *plan = NULL;
snprintf(old,sizeof(old),"tests/fixtures/project-plugin-v%u.xtc",version);
assert(xtc_project_load(p,old) == XTC_OK);
assert(xtc_graph_get_plugin(xtc_project_graph(p),0,&spec) == XTC_OK && spec.tempo == 120000 && spec.point_count == version-5);
assert(xtc_project_save(p,file) == XTC_OK && xtc_project_load(p,file) == XTC_OK);
{ xtc_result r = xtc_graph_compile(xtc_project_graph(p),48000,64,&plan);
assert((r == XTC_ERR_NOT_FOUND || r == XTC_ERR_UNSUPPORTED) && !plan); }
}
xtc_project_destroy(p); p = NULL;
assert(xtc_project_create(&p) == XTC_OK && xtc_project_add_track(p,"Audio",&track) == XTC_OK);
snprintf(media,sizeof(media),"%s/media.wav",root);
for (i = 0; i < 64; ++i) in[i] = (float)i / 100;
assert(xtc_recorder_create(media,48000,64,&rec) == XTC_OK);
xtc_recorder_push(rec,in,64); assert(xtc_recorder_finish(rec) == XTC_OK); xtc_recorder_destroy(rec);
assert(xtc_project_import_audio(p,track,media,0,&clip) == XTC_OK);
assert(xtc_audio_plan_create(p,48000,&audio) == XTC_OK);
assert(xtc_audio_plan_render(audio,0,expected,64) == XTC_OK); xtc_audio_plan_destroy(audio);
assert(xtc_project_save(p,file) == XTC_OK);
{ FILE *f = fopen(file,"r"); char text[4096]; size_t n; assert(f);
n = fread(text,1,sizeof(text)-1,f); text[n] = 0; fclose(f);
assert(strstr(text,"ASSET 6d656469612e776176\n")); }
{
char sub[512], copy[512]; xtc_clip_info c;
snprintf(sub,sizeof(sub),"%s/elsewhere",root); assert(mkdir(sub,0700) == 0);
snprintf(copy,sizeof(copy),"%s/elsewhere/copy.xtc",root);
assert(xtc_project_save(p,copy) == XTC_OK && xtc_project_load(p,copy) == XTC_OK);
assert(xtc_project_find_clip(p,clip,&c) == XTC_OK && !strcmp(c.asset_path,media));
unlink(copy); rmdir(sub);
}
snprintf(moved,sizeof(moved),"%s-moved",root); assert(rename(root,moved) == 0);
snprintf(file,sizeof(file),"%s-moved/migration.xtc",root);
assert(xtc_project_load(p,file) == XTC_OK);
{ char *cwd = getcwd(NULL,0); assert(cwd && chdir("/") == 0);
assert(xtc_audio_plan_create(p,48000,&audio) == XTC_OK && xtc_audio_plan_issues(audio,NULL) == 0);
assert(xtc_audio_plan_render(audio,0,actual,64) == XTC_OK);
for (i = 0; i < 64; ++i) assert(expected[i] == actual[i]);
xtc_audio_plan_destroy(audio); assert(chdir(cwd) == 0); free(cwd); }
snprintf(media,sizeof(media),"%s-moved/media.wav",root); assert(unlink(media) == 0);
assert(xtc_project_save(p,file) == XTC_OK && xtc_project_load(p,file) == XTC_OK);
assert(xtc_audio_plan_create(p,48000,&audio) == XTC_OK && xtc_audio_plan_issues(audio,NULL) == 1);
xtc_audio_plan_destroy(audio);
/* Unknown records/versions fail without discarding the retained clip. */
{ FILE *f = fopen(file,"a"); assert(f); fputs("FUTURE 1\n",f); fclose(f);
assert(xtc_project_load(p,file) == XTC_ERR_FORMAT);
{ xtc_clip_info c; assert(xtc_project_find_clip(p,clip,&c) == XTC_OK && c.asset_path[0]); } }
xtc_project_destroy(p); unlink(file); rmdir(moved);
puts("v1-v6 migration, v7 folder relocation, cwd independence and missing-media preservation passed.");
return 0;
}

View file

@ -190,6 +190,12 @@ int main(void)
assert(fgets(line, sizeof(line), f));
assert(fputs("XTC_PROJECT 3\n", legacy) >= 0);
while (fgets(line, sizeof(line), f)) {
/* Old formats use absolute assets, unlike the v7 writer. */
if (!strncmp(line,"ASSET ",6) && line[6] != '-') {
size_t k; fputs("ASSET ",legacy);
for (k = 0; wave[k]; ++k) fprintf(legacy,"%02x",(unsigned char)wave[k]);
fputc('\n',legacy); continue;
}
if (strncmp(line, "MIX ", 4) && strncmp(line, "AUTOMATION ", 11) &&
strncmp(line, "POINT ", 6))
assert(fputs(line, legacy) >= 0);