13 Commits
Author SHA1 Message Date
libretroadmin 0785467fdc Vulkan: a frame's descriptor sets and buffers allocate nothing
A descriptor set is made with vk_alloc_desc_set(), nothing allocated but
the set (vulkan.hpp's allocateDescriptorSetsUnique() made a std::vector
for each), and one retired while frames may still use it is given back
through the command pool's deferred list, CommandPool::DeferFree(), not
held in an object made for it. A drawer's main and vertex uniform
buffers are made with the drawer, not when it first draws: a render to
a texture may come long after the game started. Seven sites come off
the list of allocations during play; a site is allowed now by any of
its first three of the core's functions, so that a function inlined or
not names it the same.

vk_i686.sh compiles the Vulkan renderers for 32-bit Windows, as the
buildbot builds them, where a Vulkan handle is an integer and the
wrappers do not take one unasked; CI runs it.
2026-11-10 02:13:54 +01:00
LibretroAdmin 8c2e5a410e Vulkan: make the fixed and utility pipelines on the pipeline thread
A renderer hands its general, modifier volume, prepass and other fixed
pipelines to the pipeline thread when it starts, and no longer waits for
them; a draw takes one made, makes it itself if the thread has not
started on it, or waits for the thread if it has. The quad, SSAA resolve,
motion vector, texture decoder, TA decoder and compute renderer
pipelines go the same way, and one the thread could not make is made
where it is first used, failing there as it did at start. The compute
renderer's kernels are handed over first, as every frame uses them.
2026-10-28 08:23:04 +01:00
LibretroAdmin 0102585127 vulkan: pipelines made at start and on a thread, never in a frame
Both raster renderers made a polygon's pipeline the first time a polygon
of its kind was drawn, inside retro_run: 15 (per-triangle) and 19
(per-pixel) of them in Jet Grind Radio's first 24 frames, 30 with SSAA,
and one more whenever a game drew something new later on. The modifier
volumes', the per-pixel renderer's last subpass and clear, and the
quads' pipelines were made the same way, when first drawn. Now no
pipeline is made in a frame (core/rend/vulkan/pso.h).

Measured with uprobes on lavapipe (2 CPUs), Jet Grind Radio from a save
state, 300 frames. "Cold" has no pipeline cache and an empty Mesa shader
cache; "warm" is the next run, with both kept.
                         pipelines made   first 24 frames    largest of them   context reset
                         in retro_run     cold     warm      cold     warm     cold    warm
  per-triangle, GPU TA,  35 -> 0         14350 ->  3412 ->   8331 ->  515 ->   3669 -> 3182 ->
  threaded                                9589 ms  1936 ms   4911 ms  315 ms   2561 ms 1223 ms
  per-triangle, CPU TA   35 -> 0          8614 ->  3659 ->   3679 ->  846 ->    969 ->  841 ->
                                          4007     2297      1600     428       277     427
  per-pixel, GPU TA      47 -> 0         21981 ->  6432 ->  12046 ->  866 ->   2682 -> 4429 ->
                                          9411     2768      4380     296      1602    2315
  per-triangle, SSAA 4x  59 -> 0         24992 ->  8182 ->  10083 -> 1445 ->   4828 -> 3467 ->
                                         13004     4363      4464     603      1913    1190
("Pipelines made in retro_run" counts the GLSL compiles and the
pipelines, the modifier volumes' and the quads' included.) The main
thread's own CPU time over the 300 frames is about the same (1289 ->
1144 ms in the first case). The largest cold frame
left is lavapipe's: it compiles a pipeline's code for its rasterizer when
the pipeline is first drawn with, on its own thread, and on two CPUs that
competes with the pipeline worker below.

Keys. A polygon's pipeline is kept under a 64-bit key of everything it
is made of, worked out from the polygon (PipelineManager::Key(),
OITPipelineManager::Key()), and every pipeline is built from its key
alone:
- per-triangle: the shader's 15 bits (vulkan/pso_key.h), Gouraud, SSAA's
  sample rate (none, at each sample, once a pixel), cull mode, list or
  strips, depth write, depth compare op, the stencil state (none, shadow
  bit set, shadow bit cleared, the two shadow redraws), blend enable, and
  source and destination instructions;
- per-pixel: the shader's 14 bits, Gouraud, sample rate, pass and
  subpass, cull mode, depth write, depth op, stencil state (none, set,
  cleared), blend enable, colour writes, source and destination.
The states are stored as they are made, not as the polygon's register
bits, so polygons that differ only in bits the pipeline ignores now share
one. Three key collisions in the old hashes are gone with this:
- the per-triangle key had no trilinear bit, so a trilinear polygon could
  take a non-trilinear polygon's shader, or the other way round;
- the per-pixel key had no blend factors for a two-volume polygon, and
  could not tell apart unsorted translucent polygons that write depth in
  the colour pass from sorted ones;
- the sorted-triangle topology came from a global setting read when the
  pipeline was made, and a change of the setting dropped every pipeline in
  the middle of a frame. It is now part of the key, and nothing is dropped.

The general pipeline. The first time a key is drawn, its pipeline is
asked of a worker thread, and the polygon is drawn at once with its
renderer's general pipeline. That is the same SPIR-V with the
specialization constant left at -1, so the shader reads the polygon's
bits from the push constants (the per-triangle block gets a seventh word
at offset 24, the per-pixel one a word at offset 96). Cull mode,
topology, depth test, depth write and compare op, and the stencil test,
ops, masks and reference are set with the draw (VK_EXT_extended_dynamic_state),
and so are blend enable, equation and colour write mask
(VK_EXT_extended_dynamic_state3). The core enables both extensions, and
VK_EXT_pipeline_creation_cache_control, on the device it makes when the
GPU has them. There is one general pipeline for each module and subpass:
2 for each per-triangle pipeline manager (the screen's and the
textures'), 8 for each per-pixel one (4 and 14 with SSAA). They draw the same pixels as each polygon's own pipeline: every
screenshot of the test runs drawn with only the general pipelines (a
test build) is the same, byte for byte, as with the polygons' own, with
both dynamic states, with the depth ones only, and with neither.

A device without the dynamic states has a general pipeline for each fixed
state it is drawn with. Those of the keys kept from earlier runs are made
at start. One not made yet is made there and then, the draw waiting for
it. That is the one case where a draw waits, once for each such state;
the log counts them. Without dynamic depth and stencil this covers cull,
depth and stencil states; without dynamic blend, the blend factors.

When. At renderer start (context reset), the general pipelines, the
modifier volumes' (all modes and cull modes), the depth prepass's (12,
24 with SSAA), the per-pixel renderer's translucent modifier volumes',
last subpass and clear, and the quads' are made by the drawing thread and the worker together, sharing out one
list: everything a frame can need. The polygons' own pipelines are made
on the worker, the draws' first, then all the keys kept from earlier
runs (prefetched). A new size of screen keeps every pipeline: the
per-pixel renderer used to drop its polygons' pipelines on every Init().

The worker runs below normal priority (nice 10 on Linux and Android,
below normal on Windows): what it makes is never waited for, and where
there are fewer processors than busy threads the frame goes first.

Lock-free. A slot's state is an atomic the worker publishes the pipeline
with (release; the draw reads it acquire). Requests go on two intrusive
lists (cMpscList: draws' and prefetches') and the worker sleeps on an
eventcount. A manager that starts again (render pass, samples) bumps a
generation, waits for the worker to answer what it was asked, and only
then destroys its pipelines. The worker drops stale requests without
building them. The tables are made with their manager; nothing is
allocated in a frame. Without threads (NO_THREADS=1) there is no worker,
and polygons are drawn with the general pipelines.

The cache. The worker has its own VkPipelineCache, made from the same
data as the context's, and both are created EXTERNALLY_SYNCHRONIZED where
the device allows, so the driver takes no lock for them. The file
(system/dc/vulkan_pipeline.cache) now starts with a header and the keys
of the pipelines made (PsoKeyLog), then the device's data. A file
without the header is read as before, as the device's data alone. It is
saved, with the worker's cache merged in, when the game is unloaded and
again when the context goes, if pipelines were made since. It is
written beside the old file and then renamed over it.

Draws. An untextured polygon drawn with a general pipeline needs a valid
texture binding, so each frame's descriptor sets bind one (the fog
table's view) as set 1 until a texture's replaces it. The per-pixel
renderer always writes the second texture binding (the first texture
when the polygon has one volume). The per-triangle renderer's trilinear
factor is pushed for every trilinear polygon; it used to be skipped when
it was 1.0, and the shader then multiplied by whatever was pushed last.
2026-10-15 20:24:11 +02:00
LibretroAdmin d070135cff vulkan: the raster renderers' shaders as SPIR-V made with the source
Both raster renderers built every shader they drew with by running
glslang in the core, the first time a polygon of its kind came: 15
compiles inside retro_run in Jet Grind Radio's first 24 frames on the
per-triangle renderer, 22 on the per-pixel one, and one more each time
a game drew something new.

The shaders are now text files (core/rend/vulkan/shaders/) and their
SPIR-V is made by tools/rend/kernels_spv.sh, as the TA and texture
kernels' already is, into core/rend/vulkan/raster_spv.inc, which is
committed. A build needs no glslangValidator and the core compiles none
of them. `kernels_spv.sh check` (run by tools/pvr/ta_gpu_test.sh and
tools/rend/texdecode_test.sh) compares the SHA-1 of each module's text
with the one in the file, and with the same glslangValidator every word.

The #defines a shader was made from were 16 for a per-triangle polygon
and 15 for a per-pixel one: tens of thousands of possible shaders.
Most of them are now the bits of one specialization constant
(vulkan/pso_key.h). A pipeline is made with its polygon's bits, the
device folds them as constants, and what is left is what the #defines
made: the same shader, nothing more per pixel. Only what changes what a
shader declares is a module of its own: flat or smooth interpolation,
SSAA's sample rate (at each sample, once a pixel, none), the per-pixel
renderer's pass, a translucent modifier volume's mode, SSAA in the
per-pixel shaders' header, and whether the depth prepass's polygon is
textured. That is 58 modules in all, about 670 KB of SPIR-V. The depth
prepass's flags (PREPASS_*) move to pso_key.h, where its shader reads
them too. SSAA's count of samples is a specialization constant too, so
the modules do not change with it.

Written as code instead of #if, the shaders keep every operation in the
order it was. One precision change: in the per-triangle shader the
offset colour was used as the lowp input when the pixel was not in a
modifier volume, and as a highp copy when it was. It is now the highp
copy in both cases. Only GPUs that compute lowp at half precision see
any difference, and there it is two products (the per-vertex fog factor
and the bump map's angle) now computed at full precision.

The shaders' modules are made when a renderer starts (RasterModules):
creating a module from words compiles nothing.
2026-10-15 20:24:05 +02:00
LibretroAdmin 24cb5e2e51 vulkan: SSAA in both raster renderers
A new core option, SSAA (reicast_ssaa): Off, 2x, 4x, 8x; Off by default.
Each pixel of the Internal Resolution is drawn from that many samples
and resolved by the shared rule (rend/tile/pvr_resolve.h). It is taken
up when the renderer starts and when the option changes, which is a
change of size (Resize()); nothing is made, compiled or read back while
a game runs for it.

How many samples (VulkanContext::SsaaSamples()): the most of those asked
for that the device can draw colour, depth and stencil with
(ssaa_device_samples()), and then that the video memory budget the
context read at start-up has room for (ssaa_clamp_samples()): the
screen's colour and depth, per sample, after the per-pixel renderer's
lists; nothing per sample for the per-triangle renderer where the device
has lazily allocated memory, since its sample attachments are transient. One line in the log says what was asked and
what was taken. It needs sample rate shading, which the core enables on
the device it makes (VkCreateDevice); a device the frontend made without
the core gets no SSAA, and says so.

The per-triangle renderer (drawer.cpp, pipeline.cpp):
- The screen's and the textures' render passes (MakeSsaaRenderPass())
  draw into colour and depth attachments of N samples, transient, cleared
  and never stored (load CLEAR, store DONT_CARE), and a second subpass
  resolves the colour into the picture (SsaaResolve, quad.cpp): a
  triangle over the framebuffer reads each sample through a
  subpassInputMS, as the unorm8 the attachment holds (packUnorm4x8()),
  and writes (sum + N/2) / N per channel. Never the device's resolve.
  The picture is all written by it, so it is not loaded.
- Pipelines get the sample count and a rate bit in their key (bit 30):
  shaded at each sample (sample shading, minSampleShading 1, the inputs
  "sample") or once a pixel. Once a pixel are the polygons drawn at a
  single rate (SsaaSingleRate(): sprites, the background, and flat strips
  where the decoder looks for them); their inputs are taken at the
  pixel's centre (interpolateAtOffset(v, 0)) where they cover all of a
  pixel's samples, which is where a frame without SSAA takes them (with
  more than one sample the device may take them elsewhere), and at the
  centroid where they cover only some, so that an edge pixel is not
  shaded from beyond the polygon. Both rates of a key are made together,
  so SSAA adds no compile of its own to the lazy creation that is there
  already.
- The last picture under a partial render, and modifier volumes, go into
  the samples; the VMUs and the OSD are drawn after the resolve.
- Renders to a texture are supersampled only where their sample
  attachments cost nothing (lazily allocated memory: tilers), with the
  attachments made at start-up as large as such a texture can be (1024 x
  the render-to-texture upscale a side). Elsewhere they are drawn with one
  sample, as without SSAA: at 4x that preallocation is 48 MB at 1024 x
  1024, more than the whole of what brute force (twice the Internal
  Resolution) adds, and what a game reads back of such a render stays what
  it is without SSAA.

The per-pixel renderer (oit/):
- Four subpasses with SSAA (RenderPasses::MakeSsaaRenderPass()): 0 depth
  and stencil, 1 colour and the translucent lists, 2 the translucent
  modifier volumes, 3 the picture put together and resolved, in one
  pass: the lists are read and sorted once a pixel, blended at each
  sample onto that sample's opaque colour, and the samples resolved by
  the shared rule. The sample attachments are one image each for all
  the render passes of a frame: a frame of one render pass never stores
  them; one that is not the last puts each sample together into them in
  subpass 2 (a per-sample variant of the same shader) and writes the
  translucent depth there, for the next render pass to draw over. They
  are not transient, since such a frame stores them.
- Textures are rendered to with one sample a pixel, as without SSAA,
  with shaders of their own (rttShaderManager): see the per-triangle
  renderer for why.
- A translucent fragment of a sorted pass is put in the lists once a
  pixel, with the samples it covers and is in front of the opaque depth
  at in eight bits of its polygon number's word (polygon numbers are
  under 2^22 then): the lists do not grow by N. Where triangles that
  share an edge cross a pixel each has a fragment there, with the
  samples that are its own; small triangles make a pixel's lists longer
  that way, and the most fragments sorted a pixel goes from 32 to 96
  with SSAA (without it, unchanged). Translucent modifier volumes count
  where they cover a pixel's first sample, so that two triangles of a
  volume sharing an edge count once.
- Stencil and depth are read as subpassInputMS at the sample a shader
  runs for (or the first one it covers, once a pixel).

Shaders without SSAA are built from the same text as before but for the
names of their inputs: a frame with SSAA off is drawn as it was (checked
against the build before, byte for byte, below).

GPU-side TA processing: the GPU's decode does not look for flat strips
(it would have to read every vertex on the CPU to choose a pipeline), so
with TA Processing = GPU only sprites and the background are drawn once
a pixel, and flat strips are shaded at each sample; ta_set_flat_strips()
is asked for only when frames are parsed on the CPU. The two give the
same picture with SSAA off, and pictures that differ by that with it on.

The log, for measuring: the GPU time of the screen's frames (two
timestamps a frame, read when the frame's slot of the command pool comes
round again, after its fence; never waited for) as mean, 99th
percentile and longest at the end (GpuFrameTimer), and the video memory
in use then (LogVramUsage()).

No change to the save state; run-ahead stays exact.

Tested on lavapipe (Mesa's software Vulkan, samples 1 and 4 only: 8x is
4x there, 2x off), RetroArch with the validation layer, over saved and
booted scenes (Jet Grind Radio, Sega Rally 2, Virtua Fighter 4, the live
test's disc), both renderers, threaded rendering on and off, TA
processing on the CPU and the GPU, run-ahead, Internal Resolution 2x:
- no validation error in any of them, SSAA on or off;
- SSAA off: the screenshots are the build before's, byte for byte;
- the live test's disc (2D: sprites and flat polygons, two render passes,
  two volumes, modifier volumes, a render to a texture, punch-through and
  sorted translucent polygons) with SSAA 4x: the build before's picture,
  byte for byte, in every one of the eight ways it was run;
- tools/threads/headless.sh, with and without reicast_ssaa=4x: the
  verdicts and the sound of the build before; ta_gpu_test and
  texdecode_test pass.

GPU time (the log above; lavapipe, so the figures only show the
direction), Jet Grind Radio, 175 frames, against brute force - SSAA off
at twice the Internal Resolution, the same samples a pixel:
- per-triangle: SSAA off 38.0 ms mean (p99 111.1), SSAA 4x 128.1
  (p99 174.9), brute force 111.0 (p99 154.6);
- per-pixel: off 110.9 (p99 155.7), 4x 472.1 (p99 575.0), brute force
  369.1 (p99 441.5).
A software rasteriser shades every sample of a pixel as it shades a
pixel, has no compressed samples and no faster MSAA path, so SSAA cannot
win there; the comparison that decides it is on the NVIDIA machines.
Video memory, worked out from the attachments made (lavapipe's heap is
the host's, shared): SSAA 4x at 640 x 480 adds 12 MB to the per-triangle
renderer (colour and depth of 4 samples, 14.7 MB, in the place of one
sample's depth) and 10 MB to the per-pixel one (in the place of its two
colour attachments and its depth); brute force adds 15 to 22 MB and 26
to 30 MB (every image at four times the pixels - the two or three
pictures of the frontend's ring, depth, and for the per-pixel renderer
its colour attachments and list heads), and the frontend's own scaling
on top.
2026-10-15 15:32:49 +02:00
LibretroAdmin b04783667e rend: empty clip hang, Vulkan swap chain growth, per-pixel discard, clips
- A render to a texture with FB_X_CLIP or FB_Y_CLIP max < min sized its
  target as a huge number; the power-of-two loop never ended. Such a
  render now draws nothing, as the chip does (GL, GL4, Vulkan, OIT).
- Vulkan: the frontend can lengthen its swap chain while running
  (fast-forward): the rings stay at the size of the start and what is
  kept per frame index grows with it (quad, OSD, framebuffer textures).
- Per-pixel renderers: no early discard for polygons whose source is the
  second accumulator or that have two volumes, and none for "destination
  times white source", which a shadow scales later.
- A polygon clipped inside its tile clip is also kept inside the pixel
  clip (upstream 4fdd28fab, done on pixel rectangles).
- Vulkan sorted triangles: odd triangles of flat strips took the next
  triangle's colour; they are emitted with the provoking vertex first.
- Vulkan: translucent depth mask (Cosmic Smash), as GL has it.
2026-10-13 11:08:04 +02:00
LibretroAdmin 7a226d7517 vulkan: no dark line around a picture carried over to a larger size
When the renderer changes size, or the frontend takes the context away
and gives another, the picture that was on the screen is drawn into the
new one - a game that renders part of the screen has the rest from it.
It is drawn with a linear sampler, the only use of that sampler, whose
addressing was "clamp to border", the border black. A picture that is
made larger is sampled half a pixel past its edge at the edges of the
screen, and what came back was the edge mixed with black: a line one
pixel wide and a quarter darker along the screen's edges, which then
stayed, copied from frame to frame, for as long as the game did not draw
there.

The sampler repeats the edge now.

Seen on Mesa's software Vulkan in RetroArch with the test disc, the size
taken 4x, 1x, 2x by a line of test code (not in this): the last column and
the right-hand part of the first and last rows of the picture, which this
disc does not draw over, were 191,191,0 where the picture is 255,255,0 and
143 in the corners - three quarters, and three quarters of that. They are
255,255,0 now, with the per-triangle renderer and the per-pixel one.
Nothing changes at a size that stays the same or goes down: there the
samples fall inside the picture.
2026-10-09 09:05:57 +02:00
LibretroAdmin 72b1b708d2 vk: a replaced quad descriptor set is destroyed after its frames' fences, not after a count of draws
The quad drawer (the kept picture, the VMU and crosshair overlays) makes
a new descriptor set when the image or sampler it draws changes, because
the old one may be in use by frames still on their way through the GPU.
It then kept the old one for 2 x swapchain + 1 more draws and freed it.
A number of draws says nothing about what the GPU has finished: a game
that renders several times to one of the frontend's frames gets through
that many at once.

The old set goes to the command pool the draw's command buffer is of
(CommandPool::DeferDelete, as the per-pixel renderer's buffers already
do with theirs): it is destroyed when the pool comes round to the same
frame slot again, having waited on the way for the fence of every frame
submitted before then - this one included, which can itself have bound
the old set earlier in the same command buffer.

Draw() and VulkanOSD::DrawOSD() take the pool.

Run: tools/threads/live.sh on Vulkan with the validation layer
(VALIDATE=1, per-triangle renderer, the frontend's threaded video too):
passes, no validation error. Virtua Fighter 4 draws the frame it drew.
Not counted: how many replacements those runs contain.
2026-10-08 08:46:04 +02:00
LibretroAdmin 350f5f57dd vulkan: the overlay quads' descriptor sets are written when they change
The VMU screens and the light gun crosshairs are drawn as quads, each
with a descriptor set naming its texture. The set was rewritten for
every draw, with the same texture and sampler as the time before, and
picked by the frontend's frame index. That index does not move when the
game renders twice in one run, so the second render rewrote a set the
first one's commands, not yet executed, were using:

  vkUpdateDescriptorSets(): ... VkDescriptorSet is in use by
  VkCommandBuffer

The set is now written when the texture or the sampler is not what it
holds, which is once. When it does change, a new set takes its place
and the old one is kept, untouched, until no frame can be using it.
That is also one vkUpdateDescriptorSets less per overlay per frame.
2026-10-06 05:23:34 +02:00
LibretroAdmin 4bd929c953 threaded rendering: make one frame per retro_run, the one non-threaded makes
With Threaded Rendering on, nothing tied the emulation to retro_run. The
emulation thread ran free, held back only by the audio it pushed to the
frontend itself, 512 samples at a time, from its own thread, whenever
that call blocked. retro_run waited up to 100 ms for whatever render
turned up and returned a duplicate frame if none did. A render the game
started while the last one was still being drawn was thrown away. So a
retro_run could last any length, carry a new frame or not, and its audio
arrived some other time from some other thread.

Each retro_run now makes exactly one frame, the same one it makes with
Threaded Rendering off:

  - retro_run wakes the emulation thread, which runs the machine up to
    vblank and goes back to sleep, as dc_run() does in retro_run itself
    when rendering is not threaded;
  - when the game starts a render the emulation thread hands it to the
    libretro thread and sleeps; that thread reads what the frame takes
    from the game's memory (display lists, textures, framebuffer), lets
    the emulation thread go on, and draws. A render to a texture, which
    ends up back in the game's memory, keeps it asleep until it is done;
  - every render is drawn. The emulation thread waits for the one before
    to be finished; none is dropped;
  - at vblank the frame's audio is sent from the libretro thread in one
    batch, together with its video.

The renderers read some PVR registers while they draw, by which time the
emulation has moved on and may have changed them. Each render now
carries a copy of the registers made when the game started it, and the
renderers' files read through that (PVR_REGS_FOR_RENDERER), so they draw
from what non-threaded rendering would have read.

EmuBaton (core/libretro/emu_baton.h) is that handshake. There is no
lock, no sleep and no timeout in it: every change of hands is one atomic
operation and a thread with nothing to do sleeps on an eventcount. A
save state from the frontend's task thread takes the machine between
two frames.

The "synchronous rendering" and "delay frame swapping" options have
nothing left to choose and are hidden.

tools/threads/live.sh runs the test disc for the same 300 frames with
Threaded Rendering on and off and requires the same screenshot, byte
for byte, and the same save state (live_same.py leaves out the host
addresses a state carries). The old threaded path fails that: its state
differs from the non-threaded one. tools/threads/run.sh puts the new
EmuBaton under ThreadSanitizer.
2026-10-05 13:39:02 +02:00
Flyinghead f268376547 vulkan: VMU screens and lightgun crosshairs support 2020-01-07 16:05:43 +01:00
Flyinghead 66f723acef vulkan: allocate oit buffers in device-local mem. NVidia fixes
BufferData was always allocated in host visible mem, regardless of the
mem property argument.
NVidia gpus don't support blitting to 565 and 5551, which is needed for
mipmaps, so convert to 8888 instead.
Use optimal tiling for oit pointers image (linear not supported by
nvidia and probably slower anyway)
oit screen drawer uses multiple final attachments
2019-12-04 15:14:18 +01:00
Flyinghead 4592a287eb vulkan renderer 2019-12-03 17:38:51 +01:00