Esta página aún no está traducida: se muestra la versión en inglés.
Files & UGC
Los archivos llegan en partes y se procesan a medida que llegan. Subidas, assets y sus variantes derivadas, y contenido creado por los jugadores con un flujo de moderación.
Cuándo usarlo
- Los jugadores o los servicios suben blobs: sesiones por partes, reanudables, con cuotas por jugador que se pueden consultar.
- El procesamiento debe empezar antes de que termine la subida: lee el archivo como un flujo (stream), parte por parte.
- El contenido que crean los jugadores necesita un flujo de moderación:
SubmitUgc, una cola, un veredicto y hooks en ambos extremos. - Una sola imagen maestra debe servir para muchas plataformas: genera variantes (cambio de tamaño, transcodificación) y conserva el original como versión canónica.
- No lo necesitas para payloads pequeños y estructurados. Un campo de un registro de Data & Subscriptions los lleva sin necesidad de una sesión de subida.
Quién hace qué
| Actor | Qué hace en esta página |
|---|---|
player | sube partes, lee archivos como flujo y envía UGC |
moderator | revisa la cola y aprueba o rechaza los envíos |
backend-service | genera variantes de los assets; intercepta con hooks la subida y la moderación; define las cuotas |
En resumen
tank-07.png por partes, léelo mientras se sigue subiendo y úsalo como calcomanía// 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)Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
// 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);UGC, el camino del jugador:
SubmitUgc desde el cliente: una sola llamada, en todos los 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") # clTodavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
// 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"); // clLos controles a su alrededor son hooks, con el mismo contrato que en todas partes:
[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)Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unreal se suscribe a los eventos resultantes. Están planeadas funciones escritas desde el motor: grafos de Blueprint y C++ (traducido a un formato de script que valida la plataforma). Ambas se analizan y se suben como cualquier declaración, y la ejecución sigue ocurriendo en la plataforma.
Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unity se suscribe a los eventos resultantes.
Los dos hooks envuelven un paso de una operación, nunca un evento: Before(Files.Upload) decide si la subida empieza, y After(Files.SubmitUgc) se ejecuta cuando el envío ya existe y lo pone en la cola de moderación con el propio Moderation.Enqueue del módulo. Los eventos —subida completada, variante lista, UGC enviado, veredicto de moderación— van a los suscriptores, y un suscriptor no veta nada.
El modelo
Un archivo son bytes opacos más metadatos declarados —origen, tipo de contenido, tamaño—, y no es un almacén de estado a partir del cual se toman decisiones. Su vínculo con el modelo del juego va en sentido contrario: un campo de tu tipo guarda la referencia, y el archivo no sabe nada del juego.
Qué declara un tipo de archivo.
| Declara | Qué es |
|---|---|
origin | contenido creado por el equipo, generado por el juego o generado por los usuarios; de eso dependen los límites de tamaño y las políticas |
tipos de contenido admitidos | como una lista declarada, nunca deducidos a partir de los bytes |
límites de tamaño | se comprueban al abrir la sesión, con el tamaño declarado, y no en la última parte |
tamaño y orden de las partes | la subida la hace una sesión: un tamaño de parte declarado, el orden de las partes y un punto de reanudación |
derivatives | opcionalmente, variantes con nombre que genera un handler. Se puede observar si cada variante declarada está lista, así que un cliente nunca tiene que adivinar si la miniatura ya existe |
prefijo de almacenamiento | va en el campo del esquema que lleva la referencia: indica dónde viven los bytes y nada más. No es un directorio: un prefijo no se renombra ni se mueve, no tiene permisos y no admite operaciones recursivas. El archivo se sigue identificando por su id o su key, y el prefijo no participa en eso |
Qué se cumple para todo archivo.
| Siempre | Qué es |
|---|---|
la finalización | es idempotente por sesión: finalizar otra vez devuelve el mismo archivo, no uno nuevo |
una suma de verificación | es obligatoria, y si no coincide, hay un rechazo, nunca una aceptación silenciosa de bytes corruptos |
un archivo publicado | es inmutable: una edición es una versión nueva, y una referencia a una versión sigue apuntando a lo mismo |
el contenido creado por el equipo | se identifica por clave y versión, y es managed: no se edita en la consola de administración, porque su dueño es el código |
una sesión sin terminar | muere de forma observable: pasado su plazo, se cierra con un evento y sus partes se liberan |
la propiedad | sigue el predicado de dueño: los archivos generados por usuarios y por el juego tienen dueño como cualquier fila con dueño, y los archivos de un dueño siguen la política de eliminación de jugadores —en cascada, rechazo o anonimización—, que se declara y no se da por supuesta |
el acceso de lectura | puede depender de un derecho de uso (entitlement): un asset pago queda protegido por el derecho de uso de Catalog & Commerce, no por un segundo sistema de permisos |
Cada punto de extensión indica el tipo que le entrega al hook —antes de la subida, la intención de subida; después, el envío—, así que un hook nunca recibe un conjunto de datos sin tipo.
Errores
- Sin derecho de uso, la respuesta es
not found, noforbidden, para que la lista de rechazos no revele qué complementos existen. Un archivo retirado responde igual. - Una sesión expirada es un conflicto: abre una nueva.
- Una parte fuera del orden o del tamaño declarados, un tipo de contenido no declarado y un tamaño por encima del límite son rechazos de validación, y el del tamaño llega al abrir la sesión, no después de que los bytes ya viajaron.
- Una suma de verificación que no coincide es un rechazo de validación que vale la pena repetir: vuelve a enviar la parte.
- La cuota agotada es un conflicto; puedes reintentar después de liberar espacio.
- Un permiso de lectura otorgado que ya expiró responde not authenticated: pide un permiso nuevo en lugar de tratarlo como un problema de permisos.
- Que la revisión rechace un envío es un veredicto, no un rechazo: el envío se evaluó y la respuesta es negativa, con un motivo declarado, así que el siguiente paso depende del motivo.
- Superar la frecuencia de subida responde en la categoría de límite de frecuencia, con un plazo.
Límites
Las cifras llegarán con el capítulo sobre los límites de la plataforma; lo que pasa al alcanzar cada tope ya está definido.
- Tamaño de un archivo según su origen: la subida se rechaza antes de aceptar cualquier parte, no en la última.
- Tamaño de parte: la parte se rechaza como falla de validación.
- Vida útil de la sesión:
expiredcon un evento, y las partes se liberan. - Cuota de almacenamiento por proyecto y por jugador: una sesión nueva se rechaza como conflicto, y lo ya publicado nunca se borra en silencio para hacer lugar.
- Versiones guardadas del contenido creado por el equipo: se retira la más antigua, pero una versión a la que hace referencia un entorno vigente nunca se retira.
- Frecuencia de subida por actor: un límite de frecuencia con un plazo.
- Retención del contenido generado por el juego: pasado el período, se retira con un evento.