Cette page n’est pas encore traduite : la version anglaise est affichée.
Files & UGC
Les fichiers arrivent par morceaux et sont traités au fil de l’arrivée. Envois, assets et leurs variantes dérivées, et contenu créé par les joueurs, avec un circuit de modération.
Quand l’utiliser
- Des joueurs ou des services envoient des blobs : des sessions d’envoi (upload) par morceaux, reprenables, avec des quotas par joueur consultables.
- Le traitement doit démarrer avant la fin de l’envoi : lisez le fichier comme un flux, morceau par morceau.
- Le contenu créé par les joueurs a besoin d’un circuit de modération :
SubmitUgc, une file d’attente, un verdict, des hooks aux deux bouts. - Une même image maîtresse doit servir de nombreuses plateformes : dérivez des variantes (redimensionnement, transcodage) et gardez l’original comme référence.
- Inutile pour les petites charges utiles structurées. Un champ d’enregistrement de Data & Subscriptions les transporte sans session d’envoi.
Qui fait quoi
| Acteur | Ce qu’il fait sur cette page |
|---|---|
player | envoie des morceaux, lit des fichiers en flux, soumet de l’UGC |
moderator | examine la file d’attente, approuve ou rejette les soumissions |
backend-service | dérive les variantes d’assets ; pose des hooks sur l’envoi et la modération ; fixe les quotas |
En bref
tank-07.png par morceaux, le relire en cours d’envoi, l’attacher comme décalque// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();
// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);
// attach to an entity
await tank.Attach("decal", file);// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();
// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);
// attach to an entity
await tank.attach('decal', file);# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()
# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
async for chunk in read:
ingest(chunk)
# attach to an entity
await tank.attach("decal", file)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
{
if (!Result.HasValue()) { return; }
FPSUpload* Upload = Result.Value();
Upload->Parts->Create(PartNumber, Chunk);
Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
{
if (!Completed.HasValue()) { return; }
OnSkinUploaded(Completed.Value());
}));
}));
// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
[this](const TArray<uint8>& Chunk) { Ingest(Chunk); });
// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();
// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);
// attach to an entity
await tank.Attach("decal", file);L’UGC, côté joueur :
SubmitUgc depuis le client : un seul appel, dans tous les bindingsvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clLa déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
{
if (!Result.HasValue()) { return; }
Hud->ShowPending(Result.Value());
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clLes contrôles autour sont des hooks, avec le même contrat que partout ailleurs :
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);
[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);export const checkUpload = before(Files.upload, (u: UploadIntent) =>
u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));
export const screen = after(Files.submitUgc,
(s: UgcSubmission) => playserv.files.moderation.enqueue(s));@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)
@after(files.submit_ugc)
async def screen(s: UgcSubmission):
await playserv.files.moderation.enqueue(s)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; Unreal s’abonne aux événements qui en résultent. Les fonctions écrites dans le moteur sont prévues : graphes Blueprint et C++ (traduit vers une cible de script validée par la plateforme). Les deux sont analysées et publiées comme n’importe quelle déclaration ; l’exécution reste sur la plateforme.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; Unity s’abonne aux événements qui en résultent.
Les deux hooks encadrent une étape d’opération, jamais un événement : Before(Files.Upload) décide si l’envoi démarre, After(Files.SubmitUgc) s’exécute une fois la soumission créée et la place dans la file de modération via le Moderation.Enqueue du module. Les événements (envoi terminé, variante prête, UGC soumis, verdict de modération) vont aux abonnés, et un abonné n’oppose de veto à rien.
Le modèle
Un fichier, ce sont des octets opaques plus des métadonnées déclarées (origine, type de contenu, taille), et ce n’est pas un stockage d’état sur lequel on prend des décisions. Son lien avec le modèle du jeu va dans l’autre sens : c’est un champ de votre type qui porte la référence ; le fichier ne sait rien du jeu.
Ce que déclare un type de fichier.
| Déclare | Ce que c’est |
|---|---|
origin | contenu créé par l’équipe, généré par le jeu ou généré par les utilisateurs ; les limites de taille et les politiques en découlent |
types de contenu admis | sous forme de liste déclarée, jamais devinés à partir des octets |
limites de taille | vérifiées à l’ouverture de la session, d’après la taille déclarée, et non sur la dernière partie |
taille et ordre des parties | l’envoi passe par une session : une taille de partie déclarée, l’ordre des parties, un point de reprise |
dérivés | éventuellement, des variantes nommées produites par un handler. L’état de chaque variante déclarée est observable : un client n’a jamais à deviner si la vignette existe déjà |
préfixe de stockage | sur le champ du schéma qui porte la référence : l’endroit où vivent les octets, rien de plus. Ce n’est pas un répertoire : pas de renommage, pas de déplacement, pas de permissions sur un préfixe, pas d’opération récursive. Le fichier reste désigné par son id ou sa key, et le préfixe n’y joue aucun rôle |
Ce qui vaut pour tout fichier.
| Toujours | Ce que c’est |
|---|---|
la finalisation | est idempotente par session : une finalisation répétée renvoie le même fichier, pas un second |
une somme de contrôle | est obligatoire, et une non-concordance est un refus, jamais l’acceptation silencieuse d’octets corrompus |
un fichier publié | est immuable : une modification crée une nouvelle version, et une référence à une version continue de désigner ce qu’elle désignait |
le contenu créé par l'équipe | est désigné par une clé et une version, et il est managed : on ne le modifie pas dans la console d’administration, parce que c’est le code qui le détient |
une session inachevée | meurt de façon observable : passé son échéance, elle est close avec un événement et ses parties sont libérées |
la propriété | suit le prédicat de propriétaire : les fichiers générés par les utilisateurs et par le jeu ont un propriétaire comme n’importe quelle ligne possédée, et les fichiers d’un propriétaire obéissent à la politique de suppression des joueurs (cascade, refus ou anonymisation), déclarée et non supposée |
l'accès en lecture | peut dépendre d’un droit d’usage : un asset payant est protégé par le droit d’usage (entitlement) du commerce, et non par un second système de permissions |
Chaque point d’extension nomme le type qu’il transmet au hook (l’intention d’envoi avant l’envoi, la soumission après) : un hook ne reçoit donc jamais un sac de valeurs non typé.
Erreurs
- L’absence de droit d’usage répond
not found, pasforbidden: la liste des refus ne révèle pas quels contenus additionnels existent. Un fichier retiré répond de la même façon. - Une session expirée est un conflit : ouvrez-en une nouvelle.
- Une partie hors de l’ordre ou de la taille déclarés, un type de contenu non déclaré et une taille au-delà de la limite sont des refus de validation ; celui sur la taille tombe à l’ouverture de la session, pas après le transfert des octets.
- Une somme de contrôle qui ne concorde pas est un refus de validation qui vaut la peine d’être retent é : renvoyez la partie.
- Le quota épuisé est un conflit, à retenter après avoir libéré de l’espace.
- Un droit de lecture expiré répond not authenticated : demandez-en un nouveau au lieu de le traiter comme un problème de permission.
- Un rejet à la modération est un verdict, pas un refus : la soumission a été examinée et la réponse est négative, avec un motif déclaré ; la suite dépend donc du motif.
- La fréquence d’envoi dépassée répond dans la catégorie rate limit, avec un délai.
Limites
Les chiffres arriveront avec le chapitre sur les limites de la plateforme ; ce qui se passe à chaque plafond est déjà fixé.
- La taille d’un fichier selon son origine : l’envoi est refusé avant qu’aucune partie ne soit acceptée, pas sur la dernière.
- La taille d’une partie : la partie est refusée pour échec de validation.
- La durée de vie de la session :
expiredavec un événement, et les parties sont libérées. - Le quota de stockage par projet et par joueur : une nouvelle session est refusée comme conflit, et ce qui est déjà publié n’est jamais supprimé en silence pour faire de la place.
- Les versions de contenu créé par l’équipe conservées : la plus ancienne est retirée, mais une version référencée par un environnement en vigueur ne l’est jamais.
- La fréquence d’envoi par acteur : une limite de débit, avec un délai.
- La rétention du contenu généré par le jeu : passé ce délai, un retrait avec un événement.