Skip to content

Commit

Permalink
Merge pull request #15978 from unknownbrackets/softgpu-immediate
Browse files Browse the repository at this point in the history
Correct some of immediate vertex handling
  • Loading branch information
hrydgard authored Sep 7, 2022
2 parents ae23c3c + 402492a commit 17bc1f2
Show file tree
Hide file tree
Showing 19 changed files with 160 additions and 44 deletions.
2 changes: 1 addition & 1 deletion GPU/Common/FramebufferManagerCommon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ void FramebufferManagerCommon::CopyToColorFromOverlappingFramebuffers(VirtualFra
}
}

if (dst != currentRenderVfb_ && tookActions) {
if (currentRenderVfb_ && dst != currentRenderVfb_ && tookActions) {
// Will probably just change the name of the current renderpass, since one was started by the reinterpret itself.
draw_->BindFramebufferAsRenderTarget(currentRenderVfb_->fbo, { Draw::RPAction::KEEP, Draw::RPAction::KEEP, Draw::RPAction::KEEP }, "After Reinterpret");
}
Expand Down
3 changes: 3 additions & 0 deletions GPU/Common/VertexDecoderCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
#include "GPU/ge_constants.h"
#include "GPU/Common/ShaderCommon.h"
#include "GPU/GPUCommon.h"
#include "GPU/GPUState.h"

#if PPSSPP_ARCH(ARM)
#include "Common/ArmEmitter.h"
Expand Down Expand Up @@ -299,6 +300,8 @@ class VertexReader {
bool hasNormal() const { return decFmt_.nrmfmt != 0; }
bool hasUV() const { return decFmt_.uvfmt != 0; }
bool isThrough() const { return (vtype_ & GE_VTYPE_THROUGH) != 0; }
bool skinningEnabled() const { return vertTypeIsSkinningEnabled(vtype_); }
int numBoneWeights() const { return vertTypeGetNumBoneWeights(vtype_); }
void Goto(int index) {
data_ = base_ + index * decFmt_.stride;
}
Expand Down
3 changes: 2 additions & 1 deletion GPU/Debugger/Debugger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ void SetBreakNext(BreakNext next) {
GPUBreakpoints::AddCmdBreakpoint(GE_CMD_PRIM, true);
GPUBreakpoints::AddCmdBreakpoint(GE_CMD_BEZIER, true);
GPUBreakpoints::AddCmdBreakpoint(GE_CMD_SPLINE, true);
GPUBreakpoints::AddCmdBreakpoint(GE_CMD_VAP, true);
} else if (next == BreakNext::CURVE) {
GPUBreakpoints::AddCmdBreakpoint(GE_CMD_BEZIER, true);
GPUBreakpoints::AddCmdBreakpoint(GE_CMD_SPLINE, true);
Expand Down Expand Up @@ -111,7 +112,7 @@ bool NotifyCommand(u32 pc) {
}

bool process = true;
if (cmd == GE_CMD_PRIM || cmd == GE_CMD_BEZIER || cmd == GE_CMD_SPLINE) {
if (cmd == GE_CMD_PRIM || cmd == GE_CMD_BEZIER || cmd == GE_CMD_SPLINE || cmd == GE_CMD_VAP) {
primsThisFrame++;

if (!restrictPrimRanges.empty()) {
Expand Down
2 changes: 0 additions & 2 deletions GPU/Debugger/GECommandTable.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,7 @@ static constexpr GECmdInfo geCmdInfo[] = {
{ GE_CMD_VTCT, "immt", GECmdFormat::FLOAT },
{ GE_CMD_VTCQ, "immq", GECmdFormat::FLOAT },
{ GE_CMD_VCV, "immrgb", GECmdFormat::RGB },
// TODO: Confirm if any other bits are used?
{ GE_CMD_VAP, "imma_prim", GECmdFormat::ALPHA_PRIM },
// TODO: Confirm it's 8 bit?
{ GE_CMD_VFC, "immfog", GECmdFormat::DATA8 },
{ GE_CMD_VSCV, "immrgb1", GECmdFormat::RGB },
{ GE_CMD_UNKNOWN_FA, "unknownfa", GECmdFormat::NONE },
Expand Down
2 changes: 1 addition & 1 deletion GPU/Debugger/GECommandTable.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ enum class GECmdFormat {
BLEND_MODE, // 4 bits srcfactor, 4 bits dstfactor, 3 bits equation.
DITHER_ROW, // 4 s.3.0 fixed point dither offsets.
LOGIC_OP, // 4 bits logic operation.
ALPHA_PRIM, // 8 bits alpha, 3 bits primitive type.
ALPHA_PRIM, // 8 bits alpha, 3 bits primitive type, 1 bit antialias, 6 bit clip?, 1 bit shading, 1 bit cullenable, 1 bit cullface, 1 bit tex enable, 1 bit fog, 1 bit dither.
};

struct GECmdInfo {
Expand Down
98 changes: 83 additions & 15 deletions GPU/GPUCommon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,7 @@ void GPUCommon::Execute_End(u32 op, u32 diff) {
break;

default:
FlushImm();
currentList->subIntrToken = prev & 0xFFFF;
UpdateState(GPUSTATE_DONE);
// Since we marked done, we have to restore the context now before the next list runs.
Expand Down Expand Up @@ -1651,6 +1652,7 @@ void GPUCommon::Execute_Prim(u32 op, u32 diff) {
u32 count = data & 0xFFFF;
if (count == 0)
return;
FlushImm();

// Upper bits are ignored.
GEPrimitiveType prim = static_cast<GEPrimitiveType>((data >> 16) & 7);
Expand Down Expand Up @@ -2379,40 +2381,60 @@ void GPUCommon::Execute_ImmVertexAlphaPrim(u32 op, u32 diff) {
return;
}

int prim = (op >> 8) & 0x7;
if (prim != GE_PRIM_KEEP_PREVIOUS) {
// Flush before changing the prim type. Only continue can be used to continue a prim.
FlushImm();
}

TransformedVertex &v = immBuffer_[immCount_++];

// Formula deduced from ThrillVille's clear.
int offsetX = gstate.getOffsetX16();
int offsetY = gstate.getOffsetY16();
v.x = ((gstate.imm_vscx & 0xFFFFFF) - offsetX) / 16.0f;
v.y = ((gstate.imm_vscy & 0xFFFFFF) - offsetY) / 16.0f;
// ThrillVille does a clear with this, additional parameters found via tests.
// The current vtype affects how the coordinate is processed.
if (gstate.isModeThrough()) {
v.x = ((int)(gstate.imm_vscx & 0xFFFF) - 0x8000) / 16.0f;
v.y = ((int)(gstate.imm_vscy & 0xFFFF) - 0x8000) / 16.0f;
} else {
int offsetX = gstate.getOffsetX16();
int offsetY = gstate.getOffsetY16();
v.x = ((int)(gstate.imm_vscx & 0xFFFF) - offsetX) / 16.0f;
v.y = ((int)(gstate.imm_vscy & 0xFFFF) - offsetY) / 16.0f;
}
v.z = gstate.imm_vscz & 0xFFFF;
v.pos_w = 1.0f;
v.u = getFloat24(gstate.imm_vtcs);
v.v = getFloat24(gstate.imm_vtct);
v.uv_w = getFloat24(gstate.imm_vtcq);
v.color0_32 = (gstate.imm_cv & 0xFFFFFF) | (gstate.imm_ap << 24);
v.fog = 0.0f; // we have no information about the scale here
// TODO: When !gstate.isModeThrough(), direct fog coefficient (0 = entirely fog), ignore fog flag (also GE_IMM_FOG.)
v.fog = (gstate.imm_fc & 0xFF) / 255.0f;
// TODO: Apply if gstate.isUsingSecondaryColor() && !gstate.isModeThrough(), ignore lighting flag.
v.color1_32 = gstate.imm_scv & 0xFFFFFF;
int prim = (op >> 8) & 0x7;
if (prim != GE_PRIM_KEEP_PREVIOUS) {
immPrim_ = (GEPrimitiveType)prim;
} else if (prim == GE_PRIM_KEEP_PREVIOUS && immCount_ == 2) {
// Flags seem to only be respected from the first prim.
immFlags_ = op & 0x00FFF800;
} else if (prim == GE_PRIM_KEEP_PREVIOUS && immPrim_ != GE_PRIM_INVALID) {
static constexpr int flushPrimCount[] = { 1, 2, 0, 3, 0, 0, 2, 0 };
// Instead of finding a proper point to flush, we just emit a full rectangle every time one
// is finished.
FlushImm();
// Need to reset immCount_ here. If we do it in FlushImm it could get skipped by gstate_c.skipDrawReason.
immCount_ = 0;
if (immCount_ == flushPrimCount[immPrim_ & 7])
FlushImm();
} else {
ERROR_LOG_REPORT_ONCE(imm_draw_prim, G3D, "Immediate draw: Unexpected primitive %d at count %d", prim, immCount_);
}
}

void GPUCommon::FlushImm() {
if (immCount_ == 0 || immPrim_ == GE_PRIM_INVALID)
return;

SetDrawType(DRAW_PRIM, immPrim_);
framebufferManager_->SetRenderFrameBuffer(gstate_c.IsDirty(DIRTY_FRAMEBUF), gstate_c.skipDrawReason);
if (framebufferManager_)
framebufferManager_->SetRenderFrameBuffer(gstate_c.IsDirty(DIRTY_FRAMEBUF), gstate_c.skipDrawReason);
if (gstate_c.skipDrawReason & (SKIPDRAW_SKIPFRAME | SKIPDRAW_NON_DISPLAYED_FB)) {
// No idea how many cycles to skip, heh.
immCount_ = 0;
return;
}
UpdateUVScaleOffset();
Expand All @@ -2422,23 +2444,69 @@ void GPUCommon::FlushImm() {
// through vertices.
// Since the only known use is Thrillville and it only uses it to clear, we just use color and pos.
struct ImmVertex {
float uv[2];
uint32_t color;
float xyz[3];
};
ImmVertex temp[MAX_IMMBUFFER_SIZE];
uint32_t color1Used = 0;
for (int i = 0; i < immCount_; i++) {
// Since we're sending through, scale back up to w/h.
temp[i].uv[0] = immBuffer_[i].u * gstate.getTextureWidth(0);
temp[i].uv[1] = immBuffer_[i].v * gstate.getTextureHeight(0);
temp[i].color = immBuffer_[i].color0_32;
temp[i].xyz[0] = immBuffer_[i].pos[0];
temp[i].xyz[1] = immBuffer_[i].pos[1];
temp[i].xyz[2] = immBuffer_[i].pos[2];
color1Used |= immBuffer_[i].color1_32;
}
int vtype = GE_VTYPE_TC_FLOAT | GE_VTYPE_POS_FLOAT | GE_VTYPE_COL_8888 | GE_VTYPE_THROUGH;

// TODO: Handle fog and secondary color somehow?

bool antialias = (immFlags_ & GE_IMM_ANTIALIAS) != 0;
bool prevAntialias = gstate.isAntiAliasEnabled();
bool shading = (immFlags_ & GE_IMM_SHADING) != 0;
bool prevShading = gstate.getShadeMode() == GE_SHADE_GOURAUD;
bool cullEnable = (immFlags_ & GE_IMM_CULLENABLE) != 0;
bool prevCullEnable = gstate.isCullEnabled();
int cullMode = (immFlags_ & GE_IMM_CULLFACE) != 0 ? 1 : 0;
bool texturing = (immFlags_ & GE_IMM_TEXTURE) != 0;
bool prevTexturing = gstate.isTextureMapEnabled();
bool dither = (immFlags_ & GE_IMM_DITHER) != 0;
bool prevDither = gstate.isDitherEnabled();

if ((immFlags_ & GE_IMM_CLIPMASK) != 0) {
WARN_LOG_REPORT_ONCE(geimmclipvalue, G3D, "Imm vertex used clip value, flags=%06x", immFlags_);
} else if ((immFlags_ & GE_IMM_FOG) != 0) {
WARN_LOG_REPORT_ONCE(geimmfog, G3D, "Imm vertex used fog, flags=%06x", immFlags_);
} else if (color1Used != 0 && gstate.isUsingSecondaryColor()) {
WARN_LOG_REPORT_ONCE(geimmcolor1, G3D, "Imm vertex used secondary color, flags=%06x", immFlags_);
}

if (texturing != prevTexturing || cullEnable != prevCullEnable || dither != prevDither || prevShading != shading) {
DispatchFlush();
gstate.antiAliasEnable = (GE_CMD_ANTIALIASENABLE << 24) | (int)antialias;
gstate.shademodel = (GE_CMD_SHADEMODE << 24) | (int)shading;
gstate.cullfaceEnable = (GE_CMD_CULLFACEENABLE << 24) | (int)cullEnable;
gstate.textureMapEnable = (GE_CMD_TEXTUREMAPENABLE << 24) | (int)texturing;
gstate.ditherEnable = (GE_CMD_DITHERENABLE << 24) | (int)dither;
gstate_c.Dirty(DIRTY_VERTEXSHADER_STATE | DIRTY_FRAGMENTSHADER_STATE | DIRTY_RASTER_STATE);
}
int vtype = GE_VTYPE_POS_FLOAT | GE_VTYPE_COL_8888 | GE_VTYPE_THROUGH;

int bytesRead;
uint32_t vertTypeID = GetVertTypeID(vtype, 0);
drawEngineCommon_->DispatchSubmitImm(temp, nullptr, immPrim_, immCount_, vertTypeID, gstate.getCullMode(), &bytesRead);
// TOOD: In the future, make a special path for these.
drawEngineCommon_->DispatchSubmitImm(temp, nullptr, immPrim_, immCount_, vertTypeID, cullMode, &bytesRead);
// TODO: In the future, make a special path for these.
// drawEngineCommon_->DispatchSubmitImm(immBuffer_, immCount_);
immCount_ = 0;

gstate.antiAliasEnable = (GE_CMD_ANTIALIASENABLE << 24) | (int)prevAntialias;
gstate.shademodel = (GE_CMD_SHADEMODE << 24) | (int)prevShading;
gstate.cullfaceEnable = (GE_CMD_CULLFACEENABLE << 24) | (int)prevCullEnable;
gstate.textureMapEnable = (GE_CMD_TEXTUREMAPENABLE << 24) | (int)prevTexturing;
gstate.ditherEnable = (GE_CMD_DITHERENABLE << 24) | (int)prevDither;
gstate_c.Dirty(DIRTY_VERTEXSHADER_STATE | DIRTY_FRAGMENTSHADER_STATE | DIRTY_RASTER_STATE);
}

void GPUCommon::ExecuteOp(u32 op, u32 diff) {
Expand Down
5 changes: 3 additions & 2 deletions GPU/GPUCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ class GPUCommon : public GPUInterface, public GPUDebugInterface {
void UpdatePC(u32 currentPC, u32 newPC);
void UpdateState(GPURunState state);
void FastLoadBoneMatrix(u32 target);
void FlushImm();

// TODO: Unify this.
virtual void FinishDeferred() {}
Expand Down Expand Up @@ -352,13 +353,13 @@ class GPUCommon : public GPUInterface, public GPUDebugInterface {

TransformedVertex immBuffer_[MAX_IMMBUFFER_SIZE];
int immCount_ = 0;
GEPrimitiveType immPrim_;
GEPrimitiveType immPrim_ = GE_PRIM_INVALID;
uint32_t immFlags_ = 0;

std::string reportingPrimaryInfo_;
std::string reportingFullInfo_;

private:
void FlushImm();
void CheckDepthUsage(VirtualFramebuffer *vfb);
void DoBlockTransfer(u32 skipDrawReason);
void DoExecuteCall(u32 target);
Expand Down
29 changes: 27 additions & 2 deletions GPU/GeDisasm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1354,11 +1354,36 @@ void GeDisassembleOp(u32 pc, u32 op, u32 prev, char *buffer, int bufsize) {
break;

case GE_CMD_VAP:
snprintf(buffer, bufsize, "Vertex draw: alpha=%02x, prim=%s, other=%06x", data & 0xFF, primTypes[(data >> 8) & 7], data & ~0x0007FF);
{
bool antialias = (data & GE_IMM_ANTIALIAS) != 0;
int clip = (data & GE_IMM_CLIPMASK) >> 12;
bool shading = (data & GE_IMM_SHADING) != 0;
bool cullEnable = (data & GE_IMM_CULLENABLE) != 0;
int cullMode = (data & GE_IMM_CULLFACE) != 0 ? 1 : 0;
bool texturing = (data & GE_IMM_TEXTURE) != 0;
bool dither = (data & GE_IMM_DITHER) != 0;
char *p = buffer;
p += snprintf(p, bufsize - (p - buffer), "Vertex draw: alpha=%02x, prim=%s", data & 0xFF, primTypes[(data >> 8) & 7]);
if (antialias)
p += snprintf(p, bufsize - (p - buffer), ", antialias");
if (clip != 0)
p += snprintf(p, bufsize - (p - buffer), ", clip=%02x", clip);
if (shading)
p += snprintf(p, bufsize - (p - buffer), ", shading");
if (cullEnable)
p += snprintf(p, bufsize - (p - buffer), ", cull=%s", cullMode == 1 ? "back (CCW)" : "front (CW)");
if (texturing)
p += snprintf(p, bufsize - (p - buffer), ", texturing");
if (dither)
p += snprintf(p, bufsize - (p - buffer), ", dither");
}
break;

case GE_CMD_VFC:
snprintf(buffer, bufsize, "Vertex fog: %06x", data);
if (data & ~0xFF)
snprintf(buffer, bufsize, "Vertex fog: %02x / %f (extra %04x)", data & 0xFF, (data & 0xFF) / 255.0f, data >> 8);
else
snprintf(buffer, bufsize, "Vertex fog: %02x / %f", data & 0xFF, (data & 0xFF) / 255.0f);
break;

case GE_CMD_VSCV:
Expand Down
4 changes: 2 additions & 2 deletions GPU/Software/BinManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,13 @@ BinManager::~BinManager() {
}
}

void BinManager::UpdateState() {
void BinManager::UpdateState(bool throughMode) {
PROFILE_THIS_SCOPE("bin_state");
if (HasDirty(SoftDirty::PIXEL_ALL | SoftDirty::SAMPLER_ALL | SoftDirty::RAST_ALL)) {
if (states_.Full())
Flush("states");
stateIndex_ = (int)states_.Push(RasterizerState());
ComputeRasterizerState(&states_[stateIndex_]);
ComputeRasterizerState(&states_[stateIndex_], throughMode);
states_[stateIndex_].samplerID.cached.clut = cluts_[clutIndex_].readable;

ClearDirty(SoftDirty::PIXEL_ALL | SoftDirty::SAMPLER_ALL | SoftDirty::RAST_ALL);
Expand Down
2 changes: 1 addition & 1 deletion GPU/Software/BinManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ class BinManager {
BinManager();
~BinManager();

void UpdateState();
void UpdateState(bool throughMode);
void UpdateClut(const void *src);

const Rasterizer::RasterizerState &State() {
Expand Down
6 changes: 3 additions & 3 deletions GPU/Software/Clipper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ static inline bool CheckOutsideZ(ClipCoords p, int &pos, int &neg) {
}

void ProcessRect(const VertexData &v0, const VertexData &v1, BinManager &binner) {
if (!gstate.isModeThrough()) {
if (!binner.State().throughMode) {
// We may discard the entire rect based on depth values.
int outsidePos = 0, outsideNeg = 0;
CheckOutsideZ(v0.clippos, outsidePos, outsideNeg);
Expand Down Expand Up @@ -181,7 +181,7 @@ void ProcessPoint(const VertexData &v0, BinManager &binner) {
}

void ProcessLine(const VertexData &v0, const VertexData &v1, BinManager &binner) {
if (gstate.isModeThrough()) {
if (binner.State().throughMode) {
// Actually, should clip this one too so we don't need to do bounds checks in the rasterizer.
binner.AddLine(v0, v1);
return;
Expand Down Expand Up @@ -221,7 +221,7 @@ void ProcessLine(const VertexData &v0, const VertexData &v1, BinManager &binner)

void ProcessTriangle(const VertexData &v0, const VertexData &v1, const VertexData &v2, const VertexData &provoking, BinManager &binner) {
int mask = 0;
if (!gstate.isModeThrough()) {
if (!binner.State().throughMode) {
mask |= CalcClipMask(v0.clippos);
mask |= CalcClipMask(v1.clippos);
mask |= CalcClipMask(v2.clippos);
Expand Down
6 changes: 3 additions & 3 deletions GPU/Software/FuncId.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ static inline PixelBlendFactor OptimizeAlphaFactor(uint32_t color) {
return PixelBlendFactor::FIX;
}

void ComputePixelFuncID(PixelFuncID *id) {
void ComputePixelFuncID(PixelFuncID *id, bool throughMode) {
id->fullKey = 0;

// TODO: Could this be minz > 0x0000 || maxz < 0xFFFF? Maybe unsafe, depending on verts...
id->applyDepthRange = !gstate.isModeThrough();
id->applyDepthRange = !throughMode;
// Dither happens even in clear mode.
id->dithering = gstate.isDitherEnabled();
id->fbFormat = gstate.FrameBufFormat();
Expand Down Expand Up @@ -162,7 +162,7 @@ void ComputePixelFuncID(PixelFuncID *id) {
}

id->applyLogicOp = gstate.isLogicOpEnabled() && gstate.getLogicOp() != GE_LOGIC_COPY;
id->applyFog = gstate.isFogEnabled() && !gstate.isModeThrough();
id->applyFog = gstate.isFogEnabled() && !throughMode;
}

// Cache some values for later convenience.
Expand Down
2 changes: 1 addition & 1 deletion GPU/Software/FuncId.h
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ struct hash<SamplerID> {

};

void ComputePixelFuncID(PixelFuncID *id);
void ComputePixelFuncID(PixelFuncID *id, bool throughMode);
std::string DescribePixelFuncID(const PixelFuncID &id);

void ComputeSamplerID(SamplerID *id);
Expand Down
6 changes: 3 additions & 3 deletions GPU/Software/Rasterizer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ static inline Vec4<float> Interpolate(const float &c0, const float &c1, const fl
return Interpolate(c0, c1, c2, w0.Cast<float>(), w1.Cast<float>(), w2.Cast<float>(), wsum_recip);
}

void ComputeRasterizerState(RasterizerState *state) {
ComputePixelFuncID(&state->pixelID);
void ComputeRasterizerState(RasterizerState *state, bool throughMode) {
ComputePixelFuncID(&state->pixelID, throughMode);
state->drawPixel = Rasterizer::GetSingleFunc(state->pixelID);

state->enableTextures = gstate.isTextureMapEnabled() && !state->pixelID.clearMode;
Expand Down Expand Up @@ -140,7 +140,7 @@ void ComputeRasterizerState(RasterizerState *state) {
}

state->shadeGouraud = gstate.getShadeMode() == GE_SHADE_GOURAUD;
state->throughMode = gstate.isModeThrough();
state->throughMode = throughMode;
state->antialiasLines = gstate.isAntiAliasEnabled();

state->screenOffsetX = gstate.getOffsetX16();
Expand Down
2 changes: 1 addition & 1 deletion GPU/Software/Rasterizer.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ struct RasterizerState {
}
};

void ComputeRasterizerState(RasterizerState *state);
void ComputeRasterizerState(RasterizerState *state, bool throughMode);

// Draws a triangle if its vertices are specified in counter-clockwise order
void DrawTriangle(const VertexData &v0, const VertexData &v1, const VertexData &v2, const BinCoords &range, const RasterizerState &state);
Expand Down
Loading

0 comments on commit 17bc1f2

Please sign in to comment.